From 62fc0012d48b6f576a01c40a796d6f8ccd8e5b2f Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Mon, 3 Aug 2026 08:21:08 -0600 Subject: [PATCH 1/2] fix(reticulum): stop false Failed to apply setting on Remote Off Sidecar disable returned listener status without ok:true, so !res.ok treated a successful Off as failure. Stamp ok on disable and only treat explicit ok:false as failure in the Remote UI. --- reticulum-sidecar/src/stack/mod.rs | 36 ++++++- .../remote/RemoteSettingsSection.test.tsx | 101 ++++++++++++++++++ .../remote/RemoteSettingsSection.tsx | 12 +-- .../remote/RncpEnableRequestModal.tsx | 3 +- src/renderer/lib/pushRncpListenerPolicy.ts | 8 +- .../remote-types.isRemoteOkFailure.test.ts | 30 ++++++ src/shared/remote-types.ts | 11 ++ 7 files changed, 188 insertions(+), 13 deletions(-) create mode 100644 src/shared/remote-types.isRemoteOkFailure.test.ts diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index 7b750005d..106c0d8f0 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -2098,7 +2098,9 @@ impl StackHandle { tracing::warn!("rncp listener persist failed: {e}"); } } - return live.rncp_listener_status().await; + // Match enable's RemoteOkResponse shape — status alone has no `ok`, + // and UI clients treat missing `ok` as failure (`!res.ok`). + return with_rncp_listener_ok(live.rncp_listener_status().await); } let mode = if allowed.is_empty() { "ask" @@ -2829,6 +2831,15 @@ fn merge_live_peer_fetch( } } +/// Stamp `ok: true` onto an rncp listener status object so disable matches enable's +/// `RemoteOkResponse` contract (UI treats missing `ok` as failure). +fn with_rncp_listener_ok(mut status: serde_json::Value) -> serde_json::Value { + if let Some(map) = status.as_object_mut() { + map.insert("ok".into(), serde_json::Value::Bool(true)); + } + status +} + #[cfg(test)] mod tests { use super::*; @@ -2844,6 +2855,29 @@ mod tests { (config, storage) } + #[test] + fn with_rncp_listener_ok_stamps_ok_true_on_status() { + let status = serde_json::json!({ + "enabled": false, + "inbound_mode": "off", + "allowed": [], + "blocked": [], + }); + let out = with_rncp_listener_ok(status); + assert_eq!( + out.get("ok").and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + out.get("enabled").and_then(serde_json::Value::as_bool), + Some(false) + ); + assert_eq!( + out.get("inbound_mode").and_then(|v| v.as_str()), + Some("off") + ); + } + #[test] fn merge_live_peer_fetch_preserves_cache_on_empty_or_error() { let mut cache = vec![PeerRow { diff --git a/src/renderer/components/remote/RemoteSettingsSection.test.tsx b/src/renderer/components/remote/RemoteSettingsSection.test.tsx index bfa6a826a..e61fd9626 100644 --- a/src/renderer/components/remote/RemoteSettingsSection.test.tsx +++ b/src/renderer/components/remote/RemoteSettingsSection.test.tsx @@ -8,11 +8,18 @@ import { DEFAULT_REMOTE_SETTINGS } from '@/renderer/lib/remoteSettingsStorage'; import { useReticulumInboundPolicyStore } from '@/renderer/stores/reticulumInboundPolicyStore'; import { useRncpTransferStore } from '@/renderer/stores/rncpTransferStore'; +const addToast = vi.fn(); + +vi.mock('@/renderer/components/Toast', () => ({ + useToast: () => ({ addToast }), +})); + describe('RemoteSettingsSection inbound apply', () => { const onSettingsChange = vi.fn(); beforeEach(() => { onSettingsChange.mockReset(); + addToast.mockReset(); useRncpTransferStore.getState().clearAll(); useReticulumInboundPolicyStore.setState({ policies: new Map(), loading: false }); vi.mocked(window.electronAPI.reticulum.rncp.setListener).mockReset(); @@ -31,6 +38,100 @@ describe('RemoteSettingsSection inbound apply', () => { }); }); + it('treats Off success when setListener returns status without ok (legacy shape)', async () => { + const user = userEvent.setup(); + // Pre-#fix sidecar disable returned listener_status with no `ok` field. + vi.mocked(window.electronAPI.reticulum.rncp.setListener).mockResolvedValue({ + enabled: false, + inbound_mode: 'off', + allowed: [], + blocked: [], + } as unknown as { ok: boolean; error?: string }); + useRncpTransferStore.getState().setListener({ + enabled: true, + inbound_mode: 'ask', + destination_hash: 'a'.repeat(32), + allowed: [], + blocked: [], + }); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Off' })); + + await waitFor(() => { + expect(window.electronAPI.reticulum.rncp.setListener).toHaveBeenCalledWith({ + enabled: false, + }); + }); + await waitFor(() => { + expect(onSettingsChange).toHaveBeenCalledWith({ inboundMode: 'off' }); + }); + expect(addToast).not.toHaveBeenCalledWith( + expect.stringMatching(/Failed to apply setting/i), + 'error', + ); + }); + + it('toasts applyFailed when Off setListener returns ok: false', async () => { + const user = userEvent.setup(); + vi.mocked(window.electronAPI.reticulum.rncp.setListener).mockResolvedValue({ + ok: false, + error: 'rncp requires live rns-stack sidecar', + }); + // Sidecar still Ask — sync after failure must not look like a successful Off. + vi.mocked(window.electronAPI.reticulum.rncp.getListener).mockResolvedValue({ + enabled: true, + inbound_mode: 'ask', + destination_hash: 'a'.repeat(32), + allowed: [], + blocked: [], + }); + useRncpTransferStore.getState().setListener({ + enabled: true, + inbound_mode: 'ask', + destination_hash: 'a'.repeat(32), + allowed: [], + blocked: [], + }); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Off' })); + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith( + expect.stringContaining('Failed to apply setting'), + 'error', + ); + }); + expect( + onSettingsChange.mock.calls.some( + (c) => (c[0] as { inboundMode?: string }).inboundMode === 'off', + ), + ).toBe(false); + }); + it('re-picks save dir when persisted path is rejected, then enables without optimistic Ask', async () => { const user = userEvent.setup(); vi.mocked(window.electronAPI.reticulum.rncp.setListener) diff --git a/src/renderer/components/remote/RemoteSettingsSection.tsx b/src/renderer/components/remote/RemoteSettingsSection.tsx index 178fd3ecd..3ae4bc920 100644 --- a/src/renderer/components/remote/RemoteSettingsSection.tsx +++ b/src/renderer/components/remote/RemoteSettingsSection.tsx @@ -12,7 +12,7 @@ import { import { writeClipboardText } from '@/renderer/lib/writeClipboardText'; import { useReticulumInboundPolicyStore } from '@/renderer/stores/reticulumInboundPolicyStore'; import { useRncpTransferStore } from '@/renderer/stores/rncpTransferStore'; -import type { RncpInboundMode } from '@/shared/remote-types'; +import { isRemoteOkFailure, type RncpInboundMode } from '@/shared/remote-types'; /** Sidecar outbound + inbound hard cap (see `MAX_RNCP_FILE_BYTES` in rncp_transfer.rs). */ export const RNCP_MAX_FILE_SIZE_LABEL = '25 MiB'; @@ -106,7 +106,7 @@ export function RemoteSettingsSection({ const res = await window.electronAPI.reticulum.rncp.setListener({ enabled: false, }); - if (!res.ok) { + if (isRemoteOkFailure(res)) { addToast( t('reticulumRemote.settings.applyFailed', { error: res.error ?? '' }), 'error', @@ -157,7 +157,7 @@ export function RemoteSettingsSection({ try { let res = await trySet(dir, jail); - if (!res.ok && isRncpPickerAllowlistError(res.error)) { + if (isRemoteOkFailure(res) && isRncpPickerAllowlistError(res.error)) { addToast(t('reticulumRemote.settings.rechooseSaveDir'), 'info'); // Re-authorize dirs from this session's picker — persisted paths are rejected after restart. const rePicked = await pickSaveDir(); @@ -178,7 +178,7 @@ export function RemoteSettingsSection({ res = await trySet(dir, jail); } - if (!res.ok) { + if (isRemoteOkFailure(res)) { const err = res.error ?? ''; addToast( isRncpPickerAllowlistError(err) @@ -239,7 +239,7 @@ export function RemoteSettingsSection({ allowed, blocked, }); - if (!res.ok && isRncpPickerAllowlistError(res.error)) { + if (isRemoteOkFailure(res) && isRncpPickerAllowlistError(res.error)) { addToast(t('reticulumRemote.settings.rechooseSaveDir'), 'info'); const rePicked = await pickSaveDir(); if (!rePicked) { @@ -266,7 +266,7 @@ export function RemoteSettingsSection({ blocked, }); } - if (!res.ok) { + if (isRemoteOkFailure(res)) { console.warn('[RemoteSettingsSection] pushPolicy ' + (res.error ?? '')); if (isRncpPickerAllowlistError(res.error)) { addToast(t('reticulumRemote.settings.rechooseSaveDir'), 'error'); diff --git a/src/renderer/components/remote/RncpEnableRequestModal.tsx b/src/renderer/components/remote/RncpEnableRequestModal.tsx index 244e835d4..5737dbb78 100644 --- a/src/renderer/components/remote/RncpEnableRequestModal.tsx +++ b/src/renderer/components/remote/RncpEnableRequestModal.tsx @@ -16,6 +16,7 @@ import { useReticulumInboundPolicyStore } from '@/renderer/stores/reticulumInbou import { useReticulumRemoteAddressStore } from '@/renderer/stores/reticulumRemoteAddressStore'; import { useRncpEnableRequestStore } from '@/renderer/stores/rncpEnableRequestStore'; import { useRncpTransferStore } from '@/renderer/stores/rncpTransferStore'; +import { isRemoteOkFailure } from '@/shared/remote-types'; import { canonicalizeReticulumDestinationHash } from '@/shared/reticulumDestinationHash'; import { buildRncpReceiveDestShareBody } from '@/shared/rncpRequestEnable'; @@ -194,7 +195,7 @@ export function RncpEnableRequestModal() { allowed, blocked, }); - if (!res.ok) { + if (isRemoteOkFailure(res)) { addToast( t('reticulumRemote.enableRequest.enableFailed', { error: res.error ?? t('common.error'), diff --git a/src/renderer/lib/pushRncpListenerPolicy.ts b/src/renderer/lib/pushRncpListenerPolicy.ts index fb1ae825e..5af5e484b 100644 --- a/src/renderer/lib/pushRncpListenerPolicy.ts +++ b/src/renderer/lib/pushRncpListenerPolicy.ts @@ -7,7 +7,7 @@ import { import { policiesToRncpLists } from '@/renderer/lib/rncpInboundPolicyLists'; import { useReticulumInboundPolicyStore } from '@/renderer/stores/reticulumInboundPolicyStore'; import { useRncpTransferStore } from '@/renderer/stores/rncpTransferStore'; -import type { RncpListenerRequest } from '@/shared/remote-types'; +import { isRemoteOkFailure, type RncpListenerRequest } from '@/shared/remote-types'; /** * Rebuild sidecar allow/block lists from SQLite policy and re-apply the listener @@ -41,10 +41,8 @@ export async function pushRncpListenerPolicy( }; try { const res = await window.electronAPI.reticulum.rncp.setListener(body); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime guard protects external or callback-mutated state. - if (res && typeof res === 'object' && 'ok' in res && !res.ok) { - const err = - 'error' in res && typeof res.error === 'string' ? res.error : 'setListener_failed'; + if (isRemoteOkFailure(res)) { + const err = typeof res.error === 'string' ? res.error : 'setListener_failed'; // Sync store from live status so optimistic Ask cannot stick after reject. try { const status = await window.electronAPI.reticulum.rncp.getListener(); diff --git a/src/shared/remote-types.isRemoteOkFailure.test.ts b/src/shared/remote-types.isRemoteOkFailure.test.ts new file mode 100644 index 000000000..31baf4b02 --- /dev/null +++ b/src/shared/remote-types.isRemoteOkFailure.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { isRemoteOkFailure } from './remote-types'; + +describe('isRemoteOkFailure', () => { + it('is false when ok is missing (legacy listener status shape)', () => { + expect( + isRemoteOkFailure({ + enabled: false, + inbound_mode: 'off', + allowed: [], + blocked: [], + }), + ).toBe(false); + }); + + it('is false when ok is true', () => { + expect(isRemoteOkFailure({ ok: true })).toBe(false); + }); + + it('is true when ok is false', () => { + expect(isRemoteOkFailure({ ok: false, error: 'save_dir_not_from_picker' })).toBe(true); + }); + + it('is false for null/non-objects', () => { + expect(isRemoteOkFailure(null)).toBe(false); + expect(isRemoteOkFailure(undefined)).toBe(false); + expect(isRemoteOkFailure('ok')).toBe(false); + }); +}); diff --git a/src/shared/remote-types.ts b/src/shared/remote-types.ts index 67ab6ffba..d0cfb0d0d 100644 --- a/src/shared/remote-types.ts +++ b/src/shared/remote-types.ts @@ -126,6 +126,17 @@ export interface RemoteOkResponse { error?: string; } +/** + * True only when a Remote-style IPC payload explicitly reports failure. + * Missing `ok` (e.g. legacy listener status JSON) is not a failure — callers + * that used `!res.ok` treated `undefined` as failed after a successful Off. + */ +export function isRemoteOkFailure( + res: unknown, +): res is RemoteOkResponse & { ok: false; error?: string } { + return res != null && typeof res === 'object' && 'ok' in res && res.ok === false; +} + /** `rnsh.stdout` / `rnsh.stderr` WS event payload (base64-encoded chunk). */ export interface RnshStreamEventPayload { session_id: string; From 01bc88383aaa6b26ae90fdf9fdd13bbfe3bc8fc0 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Mon, 3 Aug 2026 08:28:28 -0600 Subject: [PATCH 2/2] docs(reticulum): clarify rncp disable ok:true success marker comments --- reticulum-sidecar/src/stack/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index 106c0d8f0..10c8462f8 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -2098,8 +2098,8 @@ impl StackHandle { tracing::warn!("rncp listener persist failed: {e}"); } } - // Match enable's RemoteOkResponse shape — status alone has no `ok`, - // and UI clients treat missing `ok` as failure (`!res.ok`). + // Stamp explicit `ok: true` success marker (same RemoteOkResponse + // contract as enable) onto the post-disable listener status. return with_rncp_listener_ok(live.rncp_listener_status().await); } let mode = if allowed.is_empty() { @@ -2831,8 +2831,8 @@ fn merge_live_peer_fetch( } } -/// Stamp `ok: true` onto an rncp listener status object so disable matches enable's -/// `RemoteOkResponse` contract (UI treats missing `ok` as failure). +/// Stamp `ok: true` onto an rncp listener status object so disable returns the +/// same explicit RNCP success marker as enable (`RemoteOkResponse`). fn with_rncp_listener_ok(mut status: serde_json::Value) -> serde_json::Value { if let Some(map) = status.as_object_mut() { map.insert("ok".into(), serde_json::Value::Bool(true));