diff --git a/src/main/database.test.ts b/src/main/database.test.ts index 7004998fd..78921d99a 100644 --- a/src/main/database.test.ts +++ b/src/main/database.test.ts @@ -413,9 +413,33 @@ describe('app_settings table + message retention defaults (schema sync)', () => expect(INDEX_SOURCE).toContain('meshcoreRoomSync:'); expect(INDEX_SOURCE).toContain('meshcoreRoomLastPost:'); expect(INDEX_SOURCE).toContain('meshcoreRoomCredential:'); + expect(INDEX_SOURCE).toContain('reticulumRmapAnnounceIntervalMin'); + expect(INDEX_SOURCE).toContain('reticulumRmapReachableOn'); + expect(INDEX_SOURCE).toContain('reticulumRmapHeightMeters'); + expect(INDEX_SOURCE).not.toContain('reticulumRmapNotAllowed'); + expect(INDEX_SOURCE).toMatch(/key not allowed/); expect(INDEX_SOURCE).toMatch(/INSERT OR REPLACE INTO app_settings\(key, value\) VALUES/); }); + it('appSettings:set allowlists RMAP prefs for SQLite persistence', () => { + // Source-level: Electron-bound IPC cannot be exercised here; assert write path + allowlist. + const allowListBlock = INDEX_SOURCE.slice( + INDEX_SOURCE.indexOf('APP_SETTINGS_ALLOWED_KEYS'), + INDEX_SOURCE.indexOf('APP_SETTINGS_MAX_VALUE_LENGTH'), + ); + expect(allowListBlock).toContain("'reticulumRmapAnnounceIntervalMin'"); + expect(allowListBlock).toContain("'reticulumRmapReachableOn'"); + expect(allowListBlock).toContain("'reticulumRmapHeightMeters'"); + expect(allowListBlock).not.toContain("'reticulumRmapNotAllowed'"); + expect(INDEX_SOURCE).toMatch( + /isAppSettingsKeyAllowed\(key\)[\s\S]*?throw new Error\('appSettings:set: key not allowed'\)/, + ); + expect(INDEX_SOURCE).toMatch( + /\.prepareOnce\('INSERT OR REPLACE INTO app_settings\(key, value\) VALUES \(\?, \?\)'\)/, + ); + expect(INDEX_SOURCE).toMatch(/SELECT key, value FROM app_settings/); + }); + it('appSettings:set rejects oversized values to bound DB writes', () => { expect(INDEX_SOURCE).toContain('APP_SETTINGS_MAX_VALUE_LENGTH'); expect(INDEX_SOURCE).toContain('appSettingsMaxValueLengthForKey'); diff --git a/src/main/index.ts b/src/main/index.ts index 02ccd2bb5..632d891ea 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3455,6 +3455,9 @@ const APP_SETTINGS_ALLOWED_KEYS: ReadonlySet = new Set([ 'reduceMotion', 'alwaysShowMessageActions', 'reticulumAutostart', + 'reticulumRmapAnnounceIntervalMin', + 'reticulumRmapReachableOn', + 'reticulumRmapHeightMeters', /** Legacy blob; prefer meshtasticRemoteAdminKey: per-node keys. */ 'meshtasticRemoteAdminKeyByNode', ]); diff --git a/src/renderer/components/reticulum/ReticulumInterfacesPanel.test.tsx b/src/renderer/components/reticulum/ReticulumInterfacesPanel.test.tsx index 657b7f3a4..ca8a840b4 100644 --- a/src/renderer/components/reticulum/ReticulumInterfacesPanel.test.tsx +++ b/src/renderer/components/reticulum/ReticulumInterfacesPanel.test.tsx @@ -1,9 +1,10 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { axe } from 'vitest-axe'; import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; +import { GPS_SETTINGS_STORAGE_KEY } from '@/renderer/lib/gpsSource'; import { buildDefaultHubAddRequest, RETICULUM_DEFAULT_HUB_PRESETS, @@ -20,13 +21,18 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }), })); +const { addToastMock, restartStackMock } = vi.hoisted(() => ({ + addToastMock: vi.fn(), + restartStackMock: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('@/renderer/components/Toast', () => ({ - useToast: () => ({ addToast: vi.fn() }), + useToast: () => ({ addToast: addToastMock }), })); vi.mock('@/renderer/lib/sessions/reticulumSession', () => ({ tryGetReticulumSession: () => ({ - restartStack: vi.fn().mockResolvedValue(undefined), + restartStack: restartStackMock, }), })); @@ -43,8 +49,31 @@ const defaultProps = { onBeginBleConnectGrace: vi.fn(), }; +const rmapCapableRnode: ReticulumInterfaceRow = { + id: 'rnode-41f4', + name: 'RNode 41F4', + type: 'rnode', + enabled: true, + status: 'up', + serial_port: 'ble://eccf2847-e1fd-3f5f-0811-064db1639a3d', + discoverable: false, +}; + +const rmapWorldHub: ReticulumInterfaceRow = { + id: 'rmap-world', + name: 'RMAP World', + type: 'tcp', + enabled: true, + status: 'up', + host: 'rmap.world', + port: 4242, +}; + describe('ReticulumInterfacesPanel', () => { beforeEach(() => { + addToastMock.mockClear(); + restartStackMock.mockClear(); + localStorage.removeItem(GPS_SETTINGS_STORAGE_KEY); useConnectionStore.setState({ connections: {} }); useIdentityStore.setState({ identities: {}, activeIdentityId: null }); window.electronAPI.reticulum.proxyPost = vi.fn().mockResolvedValue({ ok: true }); @@ -64,6 +93,16 @@ describe('ReticulumInterfacesPanel', () => { if (path === '/api/v1/config/audit') { return Promise.resolve({ issues: [] }); } + if (path === '/api/v1/stack/settings') { + return Promise.resolve({ + enable_transport: true, + share_instance: false, + loglevel: 4, + }); + } + if (path === '/api/v1/interfaces') { + return Promise.resolve({ interfaces: [rmapCapableRnode, rmapWorldHub] }); + } return Promise.resolve({}); }); }); @@ -1110,4 +1149,107 @@ describe('ReticulumInterfacesPanel', () => { ).toBeInTheDocument(); expect(screen.queryByText('identity not configured')).not.toBeInTheDocument(); }); + + describe('RMAP discoverable toggle restart confirm', () => { + beforeEach(() => { + localStorage.setItem( + GPS_SETTINGS_STORAGE_KEY, + JSON.stringify({ staticLat: 40.19444, staticLon: -105.06722 }), + ); + }); + + it('refreshes then shows restart confirm; confirm restarts the stack', async () => { + const user = userEvent.setup(); + const onRefresh = vi.fn().mockResolvedValue(undefined); + + render( + , + ); + + await user.click( + screen.getByRole('checkbox', { + name: 'connectionPanel.reticulumInterfaces.rmapDiscoverableAria', + }), + ); + + await waitFor(() => { + expect(onRefresh).toHaveBeenCalled(); + }); + expect(await screen.findByText('reticulumRmapDiscovery.restartTitle')).toBeInTheDocument(); + expect(restartStackMock).not.toHaveBeenCalled(); + + const dialog = screen.getByRole('alertdialog'); + await user.click( + within(dialog).getByRole('button', { name: 'reticulumRmapDiscovery.restartConfirm' }), + ); + + await waitFor(() => { + expect(restartStackMock).toHaveBeenCalled(); + }); + expect(screen.queryByText('reticulumRmapDiscovery.restartTitle')).not.toBeInTheDocument(); + }); + + it('cancel closes restart confirm and shows the restart hint', async () => { + const user = userEvent.setup(); + const onRefresh = vi.fn().mockResolvedValue(undefined); + + render( + , + ); + + await user.click( + screen.getByRole('checkbox', { + name: 'connectionPanel.reticulumInterfaces.rmapDiscoverableAria', + }), + ); + + expect(await screen.findByText('reticulumRmapDiscovery.restartTitle')).toBeInTheDocument(); + + const dialog = screen.getByRole('alertdialog'); + await user.click(within(dialog).getByRole('button', { name: 'common.cancel' })); + + expect(screen.queryByText('reticulumRmapDiscovery.restartTitle')).not.toBeInTheDocument(); + expect( + screen.getByText('connectionPanel.reticulumInterfaces.restartStackHint'), + ).toBeInTheDocument(); + expect(restartStackMock).not.toHaveBeenCalled(); + }); + + it('still shows restart confirm when onRefresh rejects after a successful toggle', async () => { + const user = userEvent.setup(); + const onRefresh = vi.fn().mockRejectedValue(new Error('refresh failed')); + + render( + , + ); + + await user.click( + screen.getByRole('checkbox', { + name: 'connectionPanel.reticulumInterfaces.rmapDiscoverableAria', + }), + ); + + expect(await screen.findByText('reticulumRmapDiscovery.restartTitle')).toBeInTheDocument(); + expect(addToastMock).toHaveBeenCalledWith( + 'connectionPanel.reticulumInterfaces.rmapEnableSuccess', + 'success', + ); + expect(addToastMock).not.toHaveBeenCalledWith( + expect.stringContaining('rmapToggleFailed'), + 'error', + ); + }); + }); }); diff --git a/src/renderer/components/reticulum/ReticulumInterfacesPanel.tsx b/src/renderer/components/reticulum/ReticulumInterfacesPanel.tsx index 0ed4beb7f..d45ddaa2d 100644 --- a/src/renderer/components/reticulum/ReticulumInterfacesPanel.tsx +++ b/src/renderer/components/reticulum/ReticulumInterfacesPanel.tsx @@ -209,6 +209,7 @@ export function ReticulumInterfacesPanel({ } | null>(null); const [editingInterface, setEditingInterface] = useState(null); const [restartStackHint, setRestartStackHint] = useState(false); + const [showRmapRestartConfirm, setShowRmapRestartConfirm] = useState(false); const [addingDefaultHubs, setAddingDefaultHubs] = useState(false); const [rmapToggleBusyId, setRmapToggleBusyId] = useState(null); @@ -701,8 +702,13 @@ export function ReticulumInterfacesPanel({ }); if (synced) { addToast(t('connectionPanel.reticulumRmap.syncSuccess'), 'success'); - setRestartStackHint(true); - await onRefresh(); + try { + await onRefresh(); + } catch (e) { + // catch-no-log-ok refresh failure must not mask successful RMAP sync + console.debug('[ReticulumInterfacesPanel] rmap sync refresh ' + errLikeToLogString(e)); + } + setShowRmapRestartConfirm(true); } } catch (e) { addToast(t('connectionPanel.reticulumRmap.syncFailed'), 'error'); @@ -736,8 +742,13 @@ export function ReticulumInterfacesPanel({ : t('connectionPanel.reticulumInterfaces.rmapDisableSuccess', { name: iface.name }), 'success', ); - setRestartStackHint(true); - await onRefresh(); + try { + await onRefresh(); + } catch (e) { + // catch-no-log-ok refresh failure must not mask successful RMAP toggle + console.debug('[ReticulumInterfacesPanel] rmap toggle refresh ' + errLikeToLogString(e)); + } + setShowRmapRestartConfirm(true); } catch (e) { if (e instanceof ReticulumRmapGpsRequiredError) { addToast(t('reticulumRmapDiscovery.gpsMissingWarning'), 'error'); @@ -885,6 +896,21 @@ export function ReticulumInterfacesPanel({ }} /> ) : null} + {showRmapRestartConfirm ? ( + { + setShowRmapRestartConfirm(false); + void restartStackForInterfaceChange(); + }} + onCancel={() => { + setShowRmapRestartConfirm(false); + setRestartStackHint(true); + }} + /> + ) : null}