From aa15a1b76729cb7408d2ff7486f5554abdc06208 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 29 Jul 2026 14:37:00 -0600 Subject: [PATCH 01/21] fix(rrc): split room history to remove ineffective dynamic import Move hydrate/clear out of rrcMessagePersist so the session store cycle no longer needs await import(). --- src/renderer/components/RrcPanel.test.tsx | 4 +- src/renderer/components/RrcPanel.tsx | 2 +- src/renderer/lib/rrcMessagePersist.test.ts | 69 +------------------ src/renderer/lib/rrcMessagePersist.ts | 67 ------------------ src/renderer/lib/rrcRoomHistory.test.ts | 80 ++++++++++++++++++++++ src/renderer/lib/rrcRoomHistory.ts | 78 +++++++++++++++++++++ vitest.config.ts | 1 + 7 files changed, 163 insertions(+), 138 deletions(-) create mode 100644 src/renderer/lib/rrcRoomHistory.test.ts create mode 100644 src/renderer/lib/rrcRoomHistory.ts diff --git a/src/renderer/components/RrcPanel.test.tsx b/src/renderer/components/RrcPanel.test.tsx index d559462fb..d4ba360b8 100644 --- a/src/renderer/components/RrcPanel.test.tsx +++ b/src/renderer/components/RrcPanel.test.tsx @@ -10,7 +10,7 @@ import { resetRrcHubDisconnectSuppressForTests, } from '@/renderer/lib/rrcHubDisconnectSuppress'; import { saveRrcHubAutoJoin } from '@/renderer/lib/rrcHubPrefs'; -import { resetRrcMessagePersistForTests } from '@/renderer/lib/rrcMessagePersist'; +import { resetRrcRoomHistoryForTests } from '@/renderer/lib/rrcRoomHistory'; import { useRrcHubStore } from '@/renderer/stores/rrcHubStore'; import { useRrcSessionStore } from '@/renderer/stores/rrcSessionStore'; @@ -28,7 +28,7 @@ describe('RrcPanel', () => { useRrcSessionStore.getState().clearSession(); useRrcHubStore.setState({ hubs: new Map() }); resetRrcHubDisconnectSuppressForTests(); - resetRrcMessagePersistForTests(); + resetRrcRoomHistoryForTests(); hydrateAxeThemeColors(document.documentElement); vi.mocked(isReticulumSidecarRunning).mockResolvedValue(false); vi.mocked(window.electronAPI.reticulum.rrc.connect).mockClear(); diff --git a/src/renderer/components/RrcPanel.tsx b/src/renderer/components/RrcPanel.tsx index da7eca06c..24cab60ea 100644 --- a/src/renderer/components/RrcPanel.tsx +++ b/src/renderer/components/RrcPanel.tsx @@ -16,8 +16,8 @@ import { formatRrcErrorMessage } from '@/renderer/lib/rrcErrorHumanize'; import { setRrcHubDisconnectSuppressed } from '@/renderer/lib/rrcHubDisconnectSuppress'; import { isRrcHubAutoJoin, toggleRrcHubAutoJoin } from '@/renderer/lib/rrcHubPrefs'; import { isRrcHubLinked } from '@/renderer/lib/rrcHubSession'; -import { clearRrcRoomHistory, hydrateRrcRoomMessages } from '@/renderer/lib/rrcMessagePersist'; import { loadRrcRecentRooms, pushRrcRecentRoom } from '@/renderer/lib/rrcRecentRooms'; +import { clearRrcRoomHistory, hydrateRrcRoomMessages } from '@/renderer/lib/rrcRoomHistory'; import { dedupeRrcMembers, rrcIdentityHashesMatch } from '@/renderer/lib/rrcRoomMembers'; import { resolveRrcJoinRoomName, rrcRoomMatchKey, rrcRoomsMatch } from '@/renderer/lib/rrcRoomName'; import { diff --git a/src/renderer/lib/rrcMessagePersist.test.ts b/src/renderer/lib/rrcMessagePersist.test.ts index 96da9d54e..53b52f355 100644 --- a/src/renderer/lib/rrcMessagePersist.test.ts +++ b/src/renderer/lib/rrcMessagePersist.test.ts @@ -1,25 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useRrcSessionStore } from '../stores/rrcSessionStore'; -import { - clearRrcRoomHistory, - hydrateRrcRoomMessages, - persistRrcMessage, - resetRrcMessagePersistForTests, -} from './rrcMessagePersist'; +import { persistRrcMessage } from './rrcMessagePersist'; const HUB = '28c7c1a68c735693aa8e6b8193ed44b2'; describe('rrcMessagePersist', () => { beforeEach(() => { - resetRrcMessagePersistForTests(); useRrcSessionStore.getState().clearSession(); - vi.mocked(window.electronAPI.db.listRrcMessages).mockReset(); vi.mocked(window.electronAPI.db.insertRrcMessage).mockReset(); - vi.mocked(window.electronAPI.db.deleteRrcMessagesByRoom).mockReset(); - vi.mocked(window.electronAPI.db.listRrcMessages).mockResolvedValue([]); vi.mocked(window.electronAPI.db.insertRrcMessage).mockResolvedValue({ changes: 1 }); - vi.mocked(window.electronAPI.db.deleteRrcMessagesByRoom).mockResolvedValue({ changes: 0 }); }); it('persistRrcMessage inserts via IPC', async () => { @@ -43,61 +33,4 @@ describe('rrcMessagePersist', () => { }); }); }); - - it('hydrate merges rows and dedups against existing live messages', async () => { - useRrcSessionStore.getState().applyStatus('active', HUB, 'Hub'); - useRrcSessionStore.getState().addMessage({ - id: 'live-1', - room: 'lobby', - kind: 'msg', - body: 'live', - timestamp: 200, - }); - vi.mocked(window.electronAPI.db.listRrcMessages).mockResolvedValueOnce([ - { - message_id: 'hist-1', - hub_hash: HUB, - room: 'lobby', - sender_hash: null, - nickname: 'alice', - kind: 'msg', - body: 'old', - timestamp: 100, - }, - { - message_id: 'live-1', - hub_hash: HUB, - room: 'lobby', - sender_hash: null, - nickname: null, - kind: 'msg', - body: 'dup', - timestamp: 200, - }, - ]); - - await hydrateRrcRoomMessages(HUB, 'lobby'); - const list = useRrcSessionStore.getState().messages.get(`${HUB}::lobby`)!; - expect(list.map((m) => m.id)).toEqual(['hist-1', 'live-1']); - expect(list[1]?.body).toBe('live'); - - // Second hydrate is a no-op (session cache). - vi.mocked(window.electronAPI.db.listRrcMessages).mockClear(); - await hydrateRrcRoomMessages(HUB, 'lobby'); - expect(window.electronAPI.db.listRrcMessages).not.toHaveBeenCalled(); - }); - - it('clearRrcRoomHistory deletes SQLite and memory', async () => { - useRrcSessionStore.getState().applyStatus('active', HUB, 'Hub'); - useRrcSessionStore.getState().addMessage({ - id: 'm1', - room: 'lobby', - kind: 'msg', - body: 'x', - timestamp: 1, - }); - await clearRrcRoomHistory(HUB, 'lobby'); - expect(window.electronAPI.db.deleteRrcMessagesByRoom).toHaveBeenCalledWith(HUB, 'lobby'); - expect(useRrcSessionStore.getState().messages.get(`${HUB}::lobby`)).toBeUndefined(); - }); }); diff --git a/src/renderer/lib/rrcMessagePersist.ts b/src/renderer/lib/rrcMessagePersist.ts index b5f2ecb39..d5f3c9585 100644 --- a/src/renderer/lib/rrcMessagePersist.ts +++ b/src/renderer/lib/rrcMessagePersist.ts @@ -4,13 +4,6 @@ import type { RrcChatMessage, RrcChatMessageKind } from '@/shared/rrc-types'; const ALLOWED_KINDS = new Set(['msg', 'notice', 'action', 'error', 'system']); -/** Keys already loaded from SQLite this session (`${hub}::${room}`). */ -const hydratedRoomKeys = new Set(); - -export function resetRrcMessagePersistForTests(): void { - hydratedRoomKeys.clear(); -} - function isRrcKind(value: string): value is RrcChatMessageKind { return ALLOWED_KINDS.has(value as RrcChatMessageKind); } @@ -22,7 +15,6 @@ function storageRoomKey(room: string): string { /** * Fire-and-forget persist of one live RRC message. * Failure point: IPC/DB unavailable — log and keep in-memory copy. - * Intentionally does not import rrcSessionStore (avoids a cycle with addMessage). */ export function persistRrcMessage(hubHash: string, msg: RrcChatMessage): void { const hub = hubHash.trim().toLowerCase(); @@ -44,62 +36,3 @@ export function persistRrcMessage(hubHash: string, msg: RrcChatMessage): void { console.warn('[rrcMessagePersist] insert failed ' + errLikeToLogString(e)); }); } - -/** - * Load SQLite history for a hub+room and merge into the session store (dedup by id). - * Skips repeat loads for the same key this session unless `force`. - */ -export async function hydrateRrcRoomMessages( - hubHash: string, - room: string, - opts?: { force?: boolean }, -): Promise { - const hub = hubHash.trim().toLowerCase(); - const roomKey = storageRoomKey(room); - if (!hub || !roomKey) return; - const key = `${hub}::${roomKey}`; - if (!opts?.force && hydratedRoomKeys.has(key)) return; - try { - const rows = await window.electronAPI.db.listRrcMessages(hub, roomKey, 500); - hydratedRoomKeys.add(key); - const mapped: RrcChatMessage[] = []; - for (const row of rows) { - if (typeof row.message_id !== 'string' || typeof row.body !== 'string') continue; - if (!isRrcKind(row.kind)) continue; - mapped.push({ - id: row.message_id, - room: roomKey, - kind: row.kind, - body: row.body, - sender_hash: row.sender_hash ?? null, - nickname: row.nickname ?? null, - timestamp: Number.isFinite(row.timestamp) ? row.timestamp : 0, - }); - } - if (mapped.length > 0) { - const { useRrcSessionStore } = await import('../stores/rrcSessionStore'); - useRrcSessionStore.getState().mergeHistoryMessages(hub, roomKey, mapped); - } - } catch (e) { - console.warn('[rrcMessagePersist] hydrate failed ' + errLikeToLogString(e)); - } -} - -/** - * Destructive clear: SQLite + in-memory for one hub room. - * Failure point: IPC delete fails — still clears memory so UI matches user intent. - */ -export async function clearRrcRoomHistory(hubHash: string, room: string): Promise { - const hub = hubHash.trim().toLowerCase(); - const roomKey = storageRoomKey(room); - if (!hub || !roomKey) return; - const key = `${hub}::${roomKey}`; - try { - await window.electronAPI.db.deleteRrcMessagesByRoom(hub, roomKey); - } catch (e) { - console.warn('[rrcMessagePersist] deleteByRoom failed ' + errLikeToLogString(e)); - } - hydratedRoomKeys.delete(key); - const { useRrcSessionStore } = await import('../stores/rrcSessionStore'); - useRrcSessionStore.getState().clearRoomMessages(hub, roomKey); -} diff --git a/src/renderer/lib/rrcRoomHistory.test.ts b/src/renderer/lib/rrcRoomHistory.test.ts new file mode 100644 index 000000000..1bcf34dd8 --- /dev/null +++ b/src/renderer/lib/rrcRoomHistory.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useRrcSessionStore } from '../stores/rrcSessionStore'; +import { + clearRrcRoomHistory, + hydrateRrcRoomMessages, + resetRrcRoomHistoryForTests, +} from './rrcRoomHistory'; + +const HUB = '28c7c1a68c735693aa8e6b8193ed44b2'; + +describe('rrcRoomHistory', () => { + beforeEach(() => { + resetRrcRoomHistoryForTests(); + useRrcSessionStore.getState().clearSession(); + vi.mocked(window.electronAPI.db.listRrcMessages).mockReset(); + vi.mocked(window.electronAPI.db.insertRrcMessage).mockReset(); + vi.mocked(window.electronAPI.db.deleteRrcMessagesByRoom).mockReset(); + vi.mocked(window.electronAPI.db.listRrcMessages).mockResolvedValue([]); + vi.mocked(window.electronAPI.db.insertRrcMessage).mockResolvedValue({ changes: 1 }); + vi.mocked(window.electronAPI.db.deleteRrcMessagesByRoom).mockResolvedValue({ changes: 0 }); + }); + + it('hydrate merges rows and dedups against existing live messages', async () => { + useRrcSessionStore.getState().applyStatus('active', HUB, 'Hub'); + useRrcSessionStore.getState().addMessage({ + id: 'live-1', + room: 'lobby', + kind: 'msg', + body: 'live', + timestamp: 200, + }); + vi.mocked(window.electronAPI.db.listRrcMessages).mockResolvedValueOnce([ + { + message_id: 'hist-1', + hub_hash: HUB, + room: 'lobby', + sender_hash: null, + nickname: 'alice', + kind: 'msg', + body: 'old', + timestamp: 100, + }, + { + message_id: 'live-1', + hub_hash: HUB, + room: 'lobby', + sender_hash: null, + nickname: null, + kind: 'msg', + body: 'dup', + timestamp: 200, + }, + ]); + + await hydrateRrcRoomMessages(HUB, 'lobby'); + const list = useRrcSessionStore.getState().messages.get(`${HUB}::lobby`)!; + expect(list.map((m) => m.id)).toEqual(['hist-1', 'live-1']); + expect(list[1]?.body).toBe('live'); + + // Second hydrate is a no-op (session cache). + vi.mocked(window.electronAPI.db.listRrcMessages).mockClear(); + await hydrateRrcRoomMessages(HUB, 'lobby'); + expect(window.electronAPI.db.listRrcMessages).not.toHaveBeenCalled(); + }); + + it('clearRrcRoomHistory deletes SQLite and memory', async () => { + useRrcSessionStore.getState().applyStatus('active', HUB, 'Hub'); + useRrcSessionStore.getState().addMessage({ + id: 'm1', + room: 'lobby', + kind: 'msg', + body: 'x', + timestamp: 1, + }); + await clearRrcRoomHistory(HUB, 'lobby'); + expect(window.electronAPI.db.deleteRrcMessagesByRoom).toHaveBeenCalledWith(HUB, 'lobby'); + expect(useRrcSessionStore.getState().messages.get(`${HUB}::lobby`)).toBeUndefined(); + }); +}); diff --git a/src/renderer/lib/rrcRoomHistory.ts b/src/renderer/lib/rrcRoomHistory.ts new file mode 100644 index 000000000..8adab7072 --- /dev/null +++ b/src/renderer/lib/rrcRoomHistory.ts @@ -0,0 +1,78 @@ +import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { rrcRoomMatchKey } from '@/renderer/lib/rrcRoomName'; +import { useRrcSessionStore } from '@/renderer/stores/rrcSessionStore'; +import type { RrcChatMessage, RrcChatMessageKind } from '@/shared/rrc-types'; + +const ALLOWED_KINDS = new Set(['msg', 'notice', 'action', 'error', 'system']); + +/** Keys already loaded from SQLite this session (`${hub}::${room}`). */ +const hydratedRoomKeys = new Set(); + +export function resetRrcRoomHistoryForTests(): void { + hydratedRoomKeys.clear(); +} + +function isRrcKind(value: string): value is RrcChatMessageKind { + return ALLOWED_KINDS.has(value as RrcChatMessageKind); +} + +function storageRoomKey(room: string): string { + return rrcRoomMatchKey(room) || room.trim().toLowerCase(); +} + +/** + * Load SQLite history for a hub+room and merge into the session store (dedup by id). + * Skips repeat loads for the same key this session unless `force`. + */ +export async function hydrateRrcRoomMessages( + hubHash: string, + room: string, + opts?: { force?: boolean }, +): Promise { + const hub = hubHash.trim().toLowerCase(); + const roomKey = storageRoomKey(room); + if (!hub || !roomKey) return; + const key = `${hub}::${roomKey}`; + if (!opts?.force && hydratedRoomKeys.has(key)) return; + try { + const rows = await window.electronAPI.db.listRrcMessages(hub, roomKey, 500); + hydratedRoomKeys.add(key); + const mapped: RrcChatMessage[] = []; + for (const row of rows) { + if (typeof row.message_id !== 'string' || typeof row.body !== 'string') continue; + if (!isRrcKind(row.kind)) continue; + mapped.push({ + id: row.message_id, + room: roomKey, + kind: row.kind, + body: row.body, + sender_hash: row.sender_hash ?? null, + nickname: row.nickname ?? null, + timestamp: Number.isFinite(row.timestamp) ? row.timestamp : 0, + }); + } + if (mapped.length > 0) { + useRrcSessionStore.getState().mergeHistoryMessages(hub, roomKey, mapped); + } + } catch (e) { + console.warn('[rrcRoomHistory] hydrate failed ' + errLikeToLogString(e)); + } +} + +/** + * Destructive clear: SQLite + in-memory for one hub room. + * Failure point: IPC delete fails — still clears memory so UI matches user intent. + */ +export async function clearRrcRoomHistory(hubHash: string, room: string): Promise { + const hub = hubHash.trim().toLowerCase(); + const roomKey = storageRoomKey(room); + if (!hub || !roomKey) return; + const key = `${hub}::${roomKey}`; + try { + await window.electronAPI.db.deleteRrcMessagesByRoom(hub, roomKey); + } catch (e) { + console.warn('[rrcRoomHistory] deleteByRoom failed ' + errLikeToLogString(e)); + } + hydratedRoomKeys.delete(key); + useRrcSessionStore.getState().clearRoomMessages(hub, roomKey); +} diff --git a/vitest.config.ts b/vitest.config.ts index 8a4c9fd3a..6fa559609 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -90,6 +90,7 @@ const RENDERER_LOGIC_EXCLUDE = [ 'src/renderer/lib/letsMeshJwt.test.ts', 'src/renderer/lib/messageRetention.test.ts', 'src/renderer/lib/rrcMessagePersist.test.ts', + 'src/renderer/lib/rrcRoomHistory.test.ts', 'src/renderer/lib/nomad/micronParser.test.ts', 'src/renderer/lib/nomad/nomadPageCache.test.ts', 'src/renderer/lib/meshtasticBacklogUtils.test.ts', From a57a301f74c4b51e5db33b0d74f0565e8b997bbc Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 29 Jul 2026 14:58:15 -0600 Subject: [PATCH 02/21] fix(reticulum): fix rncp request-enable 422 and auto-share receive dest Request enable posted `content` instead of sidecar `text`, causing HTTP 422. After a peer enables inbound rncp, share their receive hash so Chat autofills. --- docs/reticulum.md | 16 ++--- docs/troubleshooting.md | 2 + src/renderer/components/Toast.tsx | 16 +++++ .../remote/ChatDmRncpControl.test.tsx | 23 ++++++- .../components/remote/ChatDmRncpControl.tsx | 8 ++- .../remote/RemoteTransferSection.tsx | 8 ++- .../remote/RncpEnableRequestModal.test.tsx | 36 ++++++++++- .../remote/RncpEnableRequestModal.tsx | 42 +++++++++++- .../lib/applyRncpReceiveDestShare.test.ts | 64 +++++++++++++++++++ src/renderer/lib/applyRncpReceiveDestShare.ts | 47 ++++++++++++++ src/renderer/lib/rncpOfferPeerMatch.test.ts | 63 ++++++++++++++++++ src/renderer/lib/rncpOfferPeerMatch.ts | 39 +++++++++++ .../lib/sendRncpRequestEnable.test.ts | 11 ++++ src/renderer/lib/sendRncpRequestEnable.ts | 5 +- src/renderer/locales/cs/translation.json | 9 ++- src/renderer/locales/de/translation.json | 9 ++- src/renderer/locales/en/translation.json | 9 ++- src/renderer/locales/es/translation.json | 9 ++- src/renderer/locales/fr/translation.json | 9 ++- src/renderer/locales/id/translation.json | 9 ++- src/renderer/locales/it/translation.json | 9 ++- src/renderer/locales/ja/translation.json | 9 ++- src/renderer/locales/ko/translation.json | 9 ++- src/renderer/locales/nl/translation.json | 9 ++- src/renderer/locales/pl/translation.json | 9 ++- src/renderer/locales/pt-BR/translation.json | 9 ++- src/renderer/locales/ru/translation.json | 9 ++- src/renderer/locales/tr/translation.json | 9 ++- src/renderer/locales/uk/translation.json | 9 ++- src/renderer/locales/zh/translation.json | 9 ++- src/renderer/runtime/useReticulumRuntime.ts | 21 +++++- src/shared/rncpRequestEnable.test.ts | 20 ++++++ src/shared/rncpRequestEnable.ts | 37 ++++++++++- vitest.config.ts | 1 + 34 files changed, 532 insertions(+), 71 deletions(-) create mode 100644 src/renderer/lib/applyRncpReceiveDestShare.test.ts create mode 100644 src/renderer/lib/applyRncpReceiveDestShare.ts create mode 100644 src/renderer/lib/rncpOfferPeerMatch.test.ts create mode 100644 src/renderer/lib/rncpOfferPeerMatch.ts diff --git a/docs/reticulum.md b/docs/reticulum.md index 05315cada..0ad400eee 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -390,14 +390,14 @@ Implementation: sibling [rsNomad](https://github.com/Colorado-Mesh/rsNomad) (`no Wire protocols are stock Reticulum utilities — mesh-client is a client (and rncp receive listener), not a private dialect. -| Scenario | Peer side | mesh-client side | -| -------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| Shell | `rnsh` / `rnsh-rs` listen; allow our identity (`-a` / allow-list) | Remote → Shell → paste `rnsh` destination hash → connect | -| Send file | `rncp -l -a ` (or mesh-client inbound Ask) | Remote → Transfer → Send to peer `rncp.receive` hash | -| Receive file | `rncp file ` | Remote → Settings → inbound Ask/allow-list; copy **My rncp receive destination** | -| Fetch | Peer `rncp -l -F -j -a ` | Remote → Transfer → Fetch remote path | -| Auth fail | Peer allow-list omits us | Error shows **not allowed** + copy our identity hash | -| Request enable | Second mesh-client | Chat/Transfer **Request enable** LXMF DM (sentinel `mesh-client:request-rncp-receive:v1`) | +| Scenario | Peer side | mesh-client side | +| -------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Shell | `rnsh` / `rnsh-rs` listen; allow our identity (`-a` / allow-list) | Remote → Shell → paste `rnsh` destination hash → connect | +| Send file | `rncp -l -a ` (or mesh-client inbound Ask) | Remote → Transfer / Chat DM → peer `rncp.receive` hash (not LXMF) | +| Receive file | `rncp file ` | Remote → Settings → inbound Ask/allow-list; copy **My rncp receive destination** | +| Fetch | Peer `rncp -l -F -j -a ` | Remote → Transfer → Fetch remote path | +| Auth fail | Peer allow-list omits us | Error shows **not allowed** + copy our identity hash | +| Request enable | Second mesh-client | Chat/Transfer **Request enable** (`mesh-client:request-rncp-receive:v1`); peer replies with `mesh-client:rncp-receive-dest:v1:` so the sender autofills | Transfers require a **high-speed** path (TCP/network); LoRa/BLE-only destinations are refused locally before a link opens. There is no byte-level resume — Retry restarts the full file. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1f7d89ff7..2c70ed93f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1257,6 +1257,8 @@ See [reticulum.md — RNode over Wi-Fi](reticulum.md#rnode-over-wi-fi). 2. For `path_constrained`, prefer a faster interface or wait for a better path; large files over slow links may not be attempted. 3. Check sidecar logs for `rnsh`/`rncp` link errors; the `reticulum:rncpSend` / `rncpFetch` IPC returns the reason key surfaced in the toast. +**Chat DM note**: the destination field is the peer's **`rncp.receive`** hash, not their LXMF/Chat hash. Prefer **Request enable** (mesh-client peers share the receive hash after they accept) or paste from their Remote → **My rncp receive destination**. + ### Reticulum Remote inbound rncp blocked (Ask mode / policy) **Symptoms**: Incoming file offers never arrive, or an offer is auto-declined; a peer reports their send was rejected. diff --git a/src/renderer/components/Toast.tsx b/src/renderer/components/Toast.tsx index 4a0707b18..1b574065f 100644 --- a/src/renderer/components/Toast.tsx +++ b/src/renderer/components/Toast.tsx @@ -22,10 +22,19 @@ interface ToastContextValue { addToast: (message: string, type?: ToastType, duration?: number) => void; } +type ToastFn = (message: string, type?: ToastType, duration?: number) => void; + const ToastContext = createContext({ addToast: () => {}, }); +/** Module bridge so non-React code (runtimes, lib) can surface toasts when the provider is mounted. */ +let externalAddToast: ToastFn | null = null; + +export function pushAppToast(message: string, type: ToastType = 'info', duration = 4000): void { + externalAddToast?.(message, type, duration); +} + export function useToast() { return useContext(ToastContext); } @@ -39,6 +48,13 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { setToasts((prev) => [...prev, { id, message, type, duration }]); }, []); + useEffect(() => { + externalAddToast = addToast; + return () => { + if (externalAddToast === addToast) externalAddToast = null; + }; + }, [addToast]); + const removeToast = useCallback((id: number) => { setToasts((prev) => prev.filter((t) => t.id !== id)); }, []); diff --git a/src/renderer/components/remote/ChatDmRncpControl.test.tsx b/src/renderer/components/remote/ChatDmRncpControl.test.tsx index 599794b83..9823336a1 100644 --- a/src/renderer/components/remote/ChatDmRncpControl.test.tsx +++ b/src/renderer/components/remote/ChatDmRncpControl.test.tsx @@ -2,17 +2,34 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it } from 'vitest'; +import { useReticulumIdentityActivityStore } from '@/renderer/stores/reticulumIdentityActivityStore'; import { useReticulumRemoteAddressStore } from '@/renderer/stores/reticulumRemoteAddressStore'; import { useRncpTransferStore } from '@/renderer/stores/rncpTransferStore'; import { ChatDmRncpControl } from './ChatDmRncpControl'; const PEER_HASH = 'a'.repeat(32); +const PEER_IDENTITY = 'd'.repeat(32); describe('ChatDmRncpControl', () => { beforeEach(() => { useRncpTransferStore.getState().clearAll(); useReticulumRemoteAddressStore.setState({ addresses: new Map(), hydrated: false }); + useReticulumIdentityActivityStore.setState({ + byDestination: new Map([ + [ + PEER_HASH, + [ + { + destination_hash: PEER_HASH, + aspect: 'lxmf.delivery', + identity_hash: PEER_IDENTITY, + last_seen: Date.now(), + }, + ], + ], + ]), + }); }); it('renders a Send file button gated to the open DM peer', () => { @@ -25,7 +42,7 @@ describe('ChatDmRncpControl', () => { transfer_id: 't1', file_name: 'a.txt', bytes: 10, - identity_hash: PEER_HASH, + identity_hash: PEER_IDENTITY, }); useRncpTransferStore.getState().applyOffer({ transfer_id: 't2', @@ -71,13 +88,13 @@ describe('ChatDmRncpControl', () => { transfer_id: 't1', file_name: 'a.txt', bytes: 10, - identity_hash: PEER_HASH, + identity_hash: PEER_IDENTITY, }); const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: 'Send file to Alice via rncp' })); await user.click(screen.getByRole('button', { name: 'Accept a.txt' })); expect(window.electronAPI.reticulum.rncp.accept).toHaveBeenCalledWith({ transfer_id: 't1' }); - expect(useRncpTransferStore.getState().pendingOffers.has('t1')).toBe(false); + expect(useRncpTransferStore.getState().pendingOffers.size).toBe(0); }); }); diff --git a/src/renderer/components/remote/ChatDmRncpControl.tsx b/src/renderer/components/remote/ChatDmRncpControl.tsx index 15af419cc..3fd5aeeef 100644 --- a/src/renderer/components/remote/ChatDmRncpControl.tsx +++ b/src/renderer/components/remote/ChatDmRncpControl.tsx @@ -7,6 +7,7 @@ import { useToast } from '@/renderer/components/Toast'; import { useRemotePathCapability } from '@/renderer/hooks/useRemotePathCapability'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { parseReticulumDestinationInput } from '@/renderer/lib/reticulum/reticulumDestinationInput'; +import { rncpOfferMatchesLxmfPeer } from '@/renderer/lib/rncpOfferPeerMatch'; import { sendRncpRequestEnable } from '@/renderer/lib/sendRncpRequestEnable'; import { useReticulumRemoteAddressStore } from '@/renderer/stores/reticulumRemoteAddressStore'; import { useRncpTransferStore } from '@/renderer/stores/rncpTransferStore'; @@ -56,8 +57,8 @@ export function ChatDmRncpControl({ const relevantOffers = useMemo( () => - [...pendingOffers.values()].filter( - (o) => o.identity_hash?.toLowerCase() === lxmfPeerHash.toLowerCase(), + [...pendingOffers.values()].filter((o) => + rncpOfferMatchesLxmfPeer(o.identity_hash, lxmfPeerHash), ), [pendingOffers, lxmfPeerHash], ); @@ -244,6 +245,9 @@ export function ChatDmRncpControl({ +

+ {t('chatPanel.rncp.destinationHelp')} +

({ describe('RncpEnableRequestModal', () => { beforeEach(() => { addToast.mockReset(); - useRncpEnableRequestStore.getState().clear(); + useRncpEnableRequestStore.setState({ prompts: [], dismissedPeers: new Set() }); useRncpEnableRequestStore.getState().enqueue({ peerHash: 'a'.repeat(32), peerLabel: 'Alice', receivedAt: Date.now(), }); + vi.mocked(window.electronAPI.reticulum.rncp.showSaveDirectoryDialog).mockResolvedValue({ + canceled: false, + path: '/tmp/rncp-inbox', + }); + vi.mocked(window.electronAPI.reticulum.rncp.setListener).mockResolvedValue({ ok: true }); + vi.mocked(window.electronAPI.reticulum.rncp.getListener).mockResolvedValue({ + enabled: true, + inbound_mode: 'ask', + allowed: [], + blocked: [], + }); + vi.mocked(window.electronAPI.reticulum.remote.getIdentity).mockResolvedValue({ + identity_hash: 'b'.repeat(32), + rncp_receive_hash: 'c'.repeat(32), + }); + vi.mocked(window.electronAPI.reticulum.proxyPost).mockReset(); + vi.mocked(window.electronAPI.reticulum.proxyPost).mockResolvedValue({ ok: true }); }); it('renders the enable-request dialog for a queued peer', () => { @@ -46,4 +64,18 @@ describe('RncpEnableRequestModal', () => { expect(useRncpEnableRequestStore.getState().prompts).toHaveLength(0); expect(useRncpEnableRequestStore.getState().dismissedPeers.has('a'.repeat(32))).toBe(true); }); + + it('shares rncp receive dest via LXMF after enable', async () => { + const user = userEvent.setup(); + render(); + await user.click( + screen.getByRole('button', { name: 'Enable inbound file offers and ask before accepting' }), + ); + await waitFor(() => { + expect(window.electronAPI.reticulum.proxyPost).toHaveBeenCalledWith('/api/v1/lxmf/send', { + destination_hash: 'a'.repeat(32), + text: expect.stringContaining(`${RNCP_RECEIVE_DEST_SHARE_PREFIX}${'c'.repeat(32)}`), + }); + }); + }); }); diff --git a/src/renderer/components/remote/RncpEnableRequestModal.tsx b/src/renderer/components/remote/RncpEnableRequestModal.tsx index 7e49b0088..3826c9353 100644 --- a/src/renderer/components/remote/RncpEnableRequestModal.tsx +++ b/src/renderer/components/remote/RncpEnableRequestModal.tsx @@ -10,6 +10,7 @@ import { useReticulumInboundPolicyStore } from '@/renderer/stores/reticulumInbou import { useRncpEnableRequestStore } from '@/renderer/stores/rncpEnableRequestStore'; import { useRncpTransferStore } from '@/renderer/stores/rncpTransferStore'; import { canonicalizeReticulumDestinationHash } from '@/shared/reticulumDestinationHash'; +import { buildRncpReceiveDestShareBody } from '@/shared/rncpRequestEnable'; /** * Resolve a Reticulum **identity** hash for an LXMF delivery destination hash. @@ -30,6 +31,39 @@ async function resolveIdentityHashForLxmfPeer(peerDestHash: string): Promise { + const dest = canonicalizeReticulumDestinationHash(peerLxmfHash); + if (!dest) return; + try { + const identity = await window.electronAPI.reticulum.remote.getIdentity(); + const receiveHash = identity?.rncp_receive_hash + ? canonicalizeReticulumDestinationHash(identity.rncp_receive_hash) + : null; + if (!receiveHash) { + console.debug('[RncpEnableRequestModal] no rncp_receive_hash to share'); + return; + } + const text = buildRncpReceiveDestShareBody(instructions, receiveHash); + const res = (await window.electronAPI.reticulum.proxyPost('/api/v1/lxmf/send', { + destination_hash: dest, + text, + })) as { ok?: boolean; error?: string }; + if (res?.ok === false) { + console.debug('[RncpEnableRequestModal] share receive dest failed: ' + (res.error ?? '')); + } + } catch (e) { + // Non-fatal: listener is already enabled; peer can copy the hash manually. + console.debug('[RncpEnableRequestModal] share receive dest ' + errLikeToLogString(e)); + } +} + /** * Modal shown when a peer sends an LXMF DM containing * `mesh-client:request-rncp-receive:v1`. Does not auto-accept files. @@ -95,7 +129,13 @@ export function RncpEnableRequestModal() { const listener = await window.electronAPI.reticulum.rncp.getListener(); useRncpTransferStore.getState().setListener(listener); addToast(t('reticulumRemote.enableRequest.enabled'), 'success'); - dismiss(current.peerHash, false); + const peerHash = current.peerHash; + dismiss(peerHash, false); + // Best-effort: tell the requester our rncp.receive dest so they can autofill. + void shareRncpReceiveDestWithPeer( + peerHash, + t('reticulumRemote.enableRequest.lxmfShareBody'), + ); } catch (e) { console.debug('[RncpEnableRequestModal] enable ' + errLikeToLogString(e)); addToast( diff --git a/src/renderer/lib/applyRncpReceiveDestShare.test.ts b/src/renderer/lib/applyRncpReceiveDestShare.test.ts new file mode 100644 index 000000000..204815033 --- /dev/null +++ b/src/renderer/lib/applyRncpReceiveDestShare.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useReticulumRemoteAddressStore } from '@/renderer/stores/reticulumRemoteAddressStore'; +import { RNCP_RECEIVE_DEST_SHARE_PREFIX } from '@/shared/rncpRequestEnable'; + +import { applyRncpReceiveDestShareFromLxmf } from './applyRncpReceiveDestShare'; + +vi.mock('@/renderer/lib/i18n', () => ({ + default: { + t: (key: string, opts?: { peer?: string }) => (opts?.peer ? `${key}:${opts.peer}` : key), + }, +})); + +describe('applyRncpReceiveDestShareFromLxmf', () => { + beforeEach(() => { + useReticulumRemoteAddressStore.getState().clear(); + vi.mocked(window.electronAPI.db.upsertReticulumRemoteAddress).mockReset(); + vi.mocked(window.electronAPI.db.listReticulumRemoteAddresses).mockReset(); + vi.mocked(window.electronAPI.db.upsertReticulumRemoteAddress).mockResolvedValue({ + changes: 1, + }); + vi.mocked(window.electronAPI.db.listReticulumRemoteAddresses).mockResolvedValue([ + { + id: 'addr-1', + label: 'Alice', + service: 'rncp', + destination_hash: 'cd'.repeat(16), + lxmf_peer_hash: 'ab'.repeat(16), + created_at: 1, + updated_at: 1, + }, + ]); + }); + + it('returns no_share for ordinary chat', async () => { + await expect( + applyRncpReceiveDestShareFromLxmf({ + senderHash: 'ab'.repeat(16), + text: 'hello', + }), + ).resolves.toEqual({ ok: false, reason: 'no_share' }); + expect(window.electronAPI.db.upsertReticulumRemoteAddress).not.toHaveBeenCalled(); + }); + + it('upserts lxmf→rncp mapping when share sentinel is present', async () => { + const sender = 'ab'.repeat(16); + const receive = 'cd'.repeat(16); + const res = await applyRncpReceiveDestShareFromLxmf({ + senderHash: sender, + senderName: 'Alice', + text: `Here is my receive dest.\n\n${RNCP_RECEIVE_DEST_SHARE_PREFIX}${receive}`, + }); + expect(res).toEqual({ ok: true, receiveHash: receive, lxmfPeerHash: sender }); + expect(window.electronAPI.db.upsertReticulumRemoteAddress).toHaveBeenCalledWith( + expect.objectContaining({ + label: 'Alice', + service: 'rncp', + destination_hash: receive, + lxmf_peer_hash: sender, + }), + ); + }); +}); diff --git a/src/renderer/lib/applyRncpReceiveDestShare.ts b/src/renderer/lib/applyRncpReceiveDestShare.ts new file mode 100644 index 000000000..fff2a5d82 --- /dev/null +++ b/src/renderer/lib/applyRncpReceiveDestShare.ts @@ -0,0 +1,47 @@ +import i18n from '@/renderer/lib/i18n'; +import { useReticulumRemoteAddressStore } from '@/renderer/stores/reticulumRemoteAddressStore'; +import { canonicalizeReticulumDestinationHash } from '@/shared/reticulumDestinationHash'; +import { parseRncpReceiveDestShare } from '@/shared/rncpRequestEnable'; + +export type ApplyRncpReceiveDestShareResult = + | { ok: true; receiveHash: string; lxmfPeerHash: string } + | { ok: false; reason: 'no_share' | 'invalid_sender' | 'upsert_failed' }; + +/** + * If an inbound LXMF body shares an rncp.receive destination, persist the + * lxmf_peer_hash → rncp destination mapping for Chat DM / Transfer autofill. + */ +export async function applyRncpReceiveDestShareFromLxmf(opts: { + senderHash: string | null | undefined; + senderName?: string | null; + text: string | null | undefined; +}): Promise { + const receiveHash = parseRncpReceiveDestShare(opts.text); + if (!receiveHash) return { ok: false, reason: 'no_share' }; + + const lxmfPeerHash = canonicalizeReticulumDestinationHash(opts.senderHash ?? ''); + if (!lxmfPeerHash) return { ok: false, reason: 'invalid_sender' }; + + const label = + opts.senderName?.trim() || + useReticulumRemoteAddressStore.getState().findByLxmfPeer(lxmfPeerHash)?.label || + lxmfPeerHash.slice(0, 12); + + const existing = useReticulumRemoteAddressStore.getState().findByLxmfPeer(lxmfPeerHash); + const row = await useReticulumRemoteAddressStore.getState().upsert({ + id: existing?.id, + label, + service: 'rncp', + destination_hash: receiveHash, + lxmf_peer_hash: lxmfPeerHash, + identity_hash: existing?.identity_hash ?? null, + }); + if (!row) return { ok: false, reason: 'upsert_failed' }; + + return { ok: true, receiveHash, lxmfPeerHash }; +} + +/** Human-readable toast after a successful share ingest. */ +export function rncpReceiveDestShareSavedToastMessage(peerLabel: string): string { + return i18n.t('reticulumRemote.transfer.receiveDestSharedToast', { peer: peerLabel }); +} diff --git a/src/renderer/lib/rncpOfferPeerMatch.test.ts b/src/renderer/lib/rncpOfferPeerMatch.test.ts new file mode 100644 index 000000000..2d8c54952 --- /dev/null +++ b/src/renderer/lib/rncpOfferPeerMatch.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { useReticulumIdentityActivityStore } from '@/renderer/stores/reticulumIdentityActivityStore'; +import { useReticulumRemoteAddressStore } from '@/renderer/stores/reticulumRemoteAddressStore'; + +import { rncpOfferMatchesLxmfPeer } from './rncpOfferPeerMatch'; + +describe('rncpOfferMatchesLxmfPeer', () => { + const lxmf = 'ab'.repeat(16); + const identity = 'cd'.repeat(16); + + beforeEach(() => { + useReticulumIdentityActivityStore.setState({ byDestination: new Map() }); + useReticulumRemoteAddressStore.getState().clear(); + }); + + it('matches via identity activity for the LXMF destination', () => { + useReticulumIdentityActivityStore.setState({ + byDestination: new Map([ + [ + lxmf, + [ + { + destination_hash: lxmf, + aspect: 'lxmf.delivery', + identity_hash: identity, + last_seen: Date.now(), + }, + ], + ], + ]), + }); + expect(rncpOfferMatchesLxmfPeer(identity, lxmf)).toBe(true); + expect(rncpOfferMatchesLxmfPeer('ee'.repeat(16), lxmf)).toBe(false); + }); + + it('matches via saved remote address identity_hash', () => { + useReticulumRemoteAddressStore.setState({ + addresses: new Map([ + [ + '1', + { + id: '1', + label: 'Peer', + service: 'rncp', + destination_hash: 'ff'.repeat(16), + identity_hash: identity, + lxmf_peer_hash: lxmf, + created_at: 1, + updated_at: 1, + }, + ], + ]), + hydrated: true, + loading: false, + }); + expect(rncpOfferMatchesLxmfPeer(identity, lxmf)).toBe(true); + }); + + it('returns false when offer has no identity', () => { + expect(rncpOfferMatchesLxmfPeer(null, lxmf)).toBe(false); + }); +}); diff --git a/src/renderer/lib/rncpOfferPeerMatch.ts b/src/renderer/lib/rncpOfferPeerMatch.ts new file mode 100644 index 000000000..458da5fec --- /dev/null +++ b/src/renderer/lib/rncpOfferPeerMatch.ts @@ -0,0 +1,39 @@ +import { useReticulumIdentityActivityStore } from '@/renderer/stores/reticulumIdentityActivityStore'; +import { useReticulumRemoteAddressStore } from '@/renderer/stores/reticulumRemoteAddressStore'; +import { canonicalizeReticulumDestinationHash } from '@/shared/reticulumDestinationHash'; + +/** + * Collect identity hashes that may identify the same peer as an LXMF DM destination. + * Used to match rncp pending offers (gated on LinkIdentify identity_hash) to a Chat DM. + */ +export function collectIdentityHashesForLxmfPeer(lxmfPeerHash: string): Set { + const out = new Set(); + const dest = canonicalizeReticulumDestinationHash(lxmfPeerHash); + if (!dest) return out; + + const activity = useReticulumIdentityActivityStore.getState().getActivity(dest); + for (const row of activity) { + const id = row.identity_hash ? canonicalizeReticulumDestinationHash(row.identity_hash) : null; + if (id) out.add(id); + } + + const saved = useReticulumRemoteAddressStore.getState().findByLxmfPeer(dest); + const savedId = saved?.identity_hash + ? canonicalizeReticulumDestinationHash(saved.identity_hash) + : null; + if (savedId) out.add(savedId); + + return out; +} + +/** True when an rncp offer's identity_hash belongs to the open LXMF DM peer. */ +export function rncpOfferMatchesLxmfPeer( + offerIdentityHash: string | null | undefined, + lxmfPeerHash: string, +): boolean { + const offerId = offerIdentityHash + ? canonicalizeReticulumDestinationHash(offerIdentityHash) + : null; + if (!offerId) return false; + return collectIdentityHashesForLxmfPeer(lxmfPeerHash).has(offerId); +} diff --git a/src/renderer/lib/sendRncpRequestEnable.test.ts b/src/renderer/lib/sendRncpRequestEnable.test.ts index cb376f01f..c753f3c53 100644 --- a/src/renderer/lib/sendRncpRequestEnable.test.ts +++ b/src/renderer/lib/sendRncpRequestEnable.test.ts @@ -6,6 +6,8 @@ vi.mock('@/renderer/lib/i18n', () => ({ }, })); +import { RNCP_REQUEST_ENABLE_SENTINEL } from '@/shared/rncpRequestEnable'; + import { resetRncpRequestEnableRateLimitForTests } from './rncpRequestEnableRateLimit'; import { sendRncpRequestEnable } from './sendRncpRequestEnable'; @@ -24,6 +26,15 @@ describe('sendRncpRequestEnable', () => { expect(window.electronAPI.reticulum.proxyPost).not.toHaveBeenCalled(); }); + it('posts destination_hash and text (sidecar field name) with sentinel', async () => { + const hash = 'ab'.repeat(16); + await expect(sendRncpRequestEnable(hash)).resolves.toEqual({ ok: true }); + expect(window.electronAPI.reticulum.proxyPost).toHaveBeenCalledWith('/api/v1/lxmf/send', { + destination_hash: hash, + text: expect.stringContaining(RNCP_REQUEST_ENABLE_SENTINEL), + }); + }); + it('rate-limits a second send to the same peer within the cooldown', async () => { const hash = 'ab'.repeat(16); await expect(sendRncpRequestEnable(hash)).resolves.toEqual({ ok: true }); diff --git a/src/renderer/lib/sendRncpRequestEnable.ts b/src/renderer/lib/sendRncpRequestEnable.ts index e14969923..b99477d0f 100644 --- a/src/renderer/lib/sendRncpRequestEnable.ts +++ b/src/renderer/lib/sendRncpRequestEnable.ts @@ -21,11 +21,12 @@ export async function sendRncpRequestEnable( return { ok: false, error: 'rate_limited' }; } const instructions = i18n.t('reticulumRemote.enableRequest.lxmfBody'); - const content = buildRncpRequestEnableMessageBody(instructions); + const text = buildRncpRequestEnableMessageBody(instructions); try { + // Sidecar LxmfSendRequest requires `text` (not `content`) — wrong key → HTTP 422. const res = (await window.electronAPI.reticulum.proxyPost('/api/v1/lxmf/send', { destination_hash: hash, - content, + text, })) as { ok?: boolean; error?: string }; if (res?.ok === false) { return { ok: false, error: 'send_failed', detail: res.error }; diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 8d896054a..55164e127 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -592,7 +592,8 @@ "sendFailed": "Odeslání se nezdařilo: {{error}}", "newOfferToast": "{{peer}} ti chce poslat soubor: {{file}}", "requestEnable": "Požádat o povolení", - "requestEnableAria": "Požádejte tohoto kolegu, aby povolil příjem souboru" + "requestEnableAria": "Požádejte tohoto kolegu, aby povolil příjem souboru", + "destinationHelp": "Ne jejich Chat/LXMF hash. Použijte Request enable — mesh-client kolegové jej po přijetí automaticky sdílejí. Nebo vložte z jejich Remote → My rncp receive destination." }, "shareLocation": "Sdílejte polohu", "shareLocationLabel": "📍 Sdílená poloha", @@ -4779,7 +4780,8 @@ "offerToast": "Příchozí nabídka souboru: {{file}}", "copyInstructionsAria": "Kopírovat pokyny pro povolení příjmu souboru", "copyInstructions": "Kopírovat pokyny", - "instructionsCopied": "Pokyny zkopírovány." + "instructionsCopied": "Pokyny zkopírovány.", + "receiveDestSharedToast": "Uložen rncp receive destination uživatele {{peer}} — nyní můžete odeslat soubor." }, "enableRequest": { "title": "Povolit příjem souborů?", @@ -4796,7 +4798,8 @@ "enableFailed": "Nelze povolit příjem souboru: {{error}}", "saveDirRequired": "Chcete-li povolit příjem, vyberte složku pro uložení.", "identityUnknown": "Hash identity tohoto peera se zatím nepodařilo zjistit — příchozí dotazování je povoleno, ale peer nebyl přidán na seznam povolených. Zkuste to znovu, až dorazí cesta nebo oznámení.", - "lxmfBody": "Povolte příjem souborů (rncp), pokud používáte mesh-client: Remote → Settings → Inbound file offers. Nebo spusťte rncp/rncp-rs v režimu poslechu." + "lxmfBody": "Povolte příjem souborů (rncp), pokud používáte mesh-client: Remote → Settings → Inbound file offers. Nebo spusťte rncp/rncp-rs v režimu poslechu.", + "lxmfShareBody": "Příjem souborů je povolen. Zde je můj rncp receive destination (mesh-client jej pro vás uloží)." }, "saved": { "labelPlaceholder": "Označení", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 57351ca98..d79d740a0 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -593,7 +593,8 @@ "sendFailed": "Senden fehlgeschlagen: {{error}}", "newOfferToast": "{{peer}} möchte dir eine Datei senden: {{file}}", "requestEnable": "Aktivieren anfordern", - "requestEnableAria": "Bitten Sie diesen Kollegen, den Dateiempfang zu aktivieren" + "requestEnableAria": "Bitten Sie diesen Kollegen, den Dateiempfang zu aktivieren", + "destinationHelp": "Nicht ihr Chat/LXMF-Hash. Request enable verwenden — mesh-client-Peers teilen dies automatisch nach der Annahme. Oder aus Remote → My rncp receive destination einfügen." }, "shareLocation": "Standort teilen", "shareLocationLabel": "📍 Geteilter Standort", @@ -4777,7 +4778,8 @@ "offerToast": "Eingehende Datei Angebot: {{file}}", "copyInstructionsAria": "Anweisungen zum Aktivieren des Dateiempfangs kopieren", "copyInstructions": "Anweisungen kopieren", - "instructionsCopied": "Anweisungen kopiert." + "instructionsCopied": "Anweisungen kopiert.", + "receiveDestSharedToast": "rncp-Empfangsziel von {{peer}} gespeichert — Sie können jetzt eine Datei senden." }, "enableRequest": { "title": "Dateiempfang aktivieren?", @@ -4794,7 +4796,8 @@ "enableFailed": "Dateiempfang konnte nicht aktiviert werden: {{error}}", "saveDirRequired": "Wählen Sie einen Speicherordner aus, um den Empfang zu aktivieren.", "identityUnknown": "Der Identitäts-Hash dieses Peers konnte noch nicht aufgelöst werden — eingehendes Nachfragen ist aktiviert, aber der Peer wurde nicht zur Erlaubnisliste hinzugefügt. Versuche es erneut, sobald ein Pfad oder ein Announce eintrifft.", - "lxmfBody": "Bitte aktivieren Sie den Dateiempfang (rncp), wenn Sie mesh-client: Remote → Settings → Inbound file offers verwenden. Oder führen Sie rncp/rncp-rs im Listen-Modus aus." + "lxmfBody": "Bitte aktivieren Sie den Dateiempfang (rncp), wenn Sie mesh-client: Remote → Settings → Inbound file offers verwenden. Oder führen Sie rncp/rncp-rs im Listen-Modus aus.", + "lxmfShareBody": "Dateiempfang ist aktiviert. Hier ist mein rncp-Empfangsziel (mesh-client speichert es für Sie)." }, "saved": { "labelPlaceholder": "Etikett", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 424ac7ad8..051778f5f 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -482,7 +482,8 @@ "sendFileAria": "Send file to {{name}} via rncp", "pendingOffersBadgeAria_one": "{{count}} pending inbound file offer from this peer", "pendingOffersBadgeAria_other": "{{count}} pending inbound file offers from this peer", - "destinationLabel": "rncp destination hash", + "destinationLabel": "Peer's rncp receive destination", + "destinationHelp": "Not their Chat/LXMF hash. Use Request enable — mesh-client peers share this automatically after they accept. Or paste from their Remote → My rncp receive destination.", "rememberAddress": "Remember this address", "rememberAddressAria": "Remember this rncp destination for this peer", "chooseAndSend": "Choose file & send…", @@ -4636,7 +4637,8 @@ "offerToast": "Incoming file offer: {{file}}", "copyInstructionsAria": "Copy instructions for enabling file receive", "copyInstructions": "Copy instructions", - "instructionsCopied": "Instructions copied." + "instructionsCopied": "Instructions copied.", + "receiveDestSharedToast": "Saved {{peer}}'s rncp receive destination — you can send a file now." }, "enableRequest": { "title": "Enable file receiving?", @@ -4653,7 +4655,8 @@ "enableFailed": "Could not enable file receiving: {{error}}", "saveDirRequired": "Choose a save folder to enable receiving.", "identityUnknown": "Could not resolve this peer's identity hash yet — inbound Ask is enabled, but they were not added to the allow list. Try again after a path or announce arrives.", - "lxmfBody": "Please enable file receiving (rncp) if you use mesh-client: Remote → Settings → Inbound file offers. Or run rncp/rncp-rs in listen mode." + "lxmfBody": "Please enable file receiving (rncp) if you use mesh-client: Remote → Settings → Inbound file offers. Or run rncp/rncp-rs in listen mode.", + "lxmfShareBody": "File receiving is enabled. Here is my rncp receive destination (mesh-client will save it for you)." }, "saved": { "labelPlaceholder": "Label", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 5c28190cb..7c806b9cf 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -593,7 +593,8 @@ "sendFailed": "Error al enviar: {{error}}", "newOfferToast": "{{peer}} quiere enviarte un archivo: {{file}}", "requestEnable": "Solicitud habilitada", - "requestEnableAria": "Pedir a este compañero que habilite la recepción de archivos" + "requestEnableAria": "Pedir a este compañero que habilite la recepción de archivos", + "destinationHelp": "No es su hash de Chat/LXMF. Use Request enable: los pares de mesh-client lo comparten automáticamente después de aceptarlo. O pegue desde Remote → My rncp receive destination." }, "shareLocation": "Compartir ubicación", "shareLocationLabel": "📍 Ubicación compartida", @@ -4777,7 +4778,8 @@ "offerToast": "Oferta de archivo entrante: {{file}}", "copyInstructionsAria": "Copiar instrucciones para habilitar la recepción de archivos", "copyInstructions": "Copiar instrucciones", - "instructionsCopied": "Instrucciones copiadas." + "instructionsCopied": "Instrucciones copiadas.", + "receiveDestSharedToast": "Se guardó el destino de recepción rncp de {{peer}}: ya puede enviar un archivo." }, "enableRequest": { "title": "¿Habilitar la recepción de archivos?", @@ -4794,7 +4796,8 @@ "enableFailed": "No se ha podido habilitar la recepción de archivos: {{error}}", "saveDirRequired": "Elija una carpeta de guardado para habilitar la recepción.", "identityUnknown": "Aún no se pudo resolver el hash de identidad de este par — el modo Preguntar está activado, pero no se añadió a la lista de permitidos. Inténtalo de nuevo cuando llegue una ruta o un anuncio.", - "lxmfBody": "Habilite la recepción de archivos (rncp) si utiliza mesh-client: → Configuración remota Ofertas de archivos → entrantes. O ejecute rncp/rncp-rs en modo de escucha." + "lxmfBody": "Habilite la recepción de archivos (rncp) si utiliza mesh-client: → Configuración remota Ofertas de archivos → entrantes. O ejecute rncp/rncp-rs en modo de escucha.", + "lxmfShareBody": "La recepción de archivos está habilitada. Aquí está mi destino de recepción rncp (mesh-client lo guardará para usted)." }, "saved": { "labelPlaceholder": "Designación", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 8f465ae88..17ce1edce 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -593,7 +593,8 @@ "sendFailed": "Échec de l'envoi : {{error}}", "newOfferToast": "{{peer}} souhaite vous envoyer un fichier : {{file}}", "requestEnable": "Demander l'activation", - "requestEnableAria": "Demandez à cet homologue d'activer la réception de fichiers" + "requestEnableAria": "Demandez à cet homologue d'activer la réception de fichiers", + "destinationHelp": "Pas leur hachage Chat/LXMF. Utilisez Request enable — les pairs mesh-client le partagent automatiquement après acceptation. Ou collez depuis Remote → My rncp receive destination." }, "shareLocation": "Partager l'emplacement", "shareLocationLabel": "📍 Emplacement partagé", @@ -4777,7 +4778,8 @@ "offerToast": "Offre de fichier entrant : {{file}}", "copyInstructionsAria": "Copier les instructions pour activer la réception de fichiers", "copyInstructions": "Copier les instructions", - "instructionsCopied": "Instructions copiées." + "instructionsCopied": "Instructions copiées.", + "receiveDestSharedToast": "Destination de réception rncp de {{peer}} enregistrée — vous pouvez envoyer un fichier." }, "enableRequest": { "title": "Activer la réception de fichiers ?", @@ -4794,7 +4796,8 @@ "enableFailed": "Impossible d'activer la réception du fichier : {{error}}", "saveDirRequired": "Choisissez un dossier de sauvegarde pour activer la réception.", "identityUnknown": "Impossible de résoudre le hachage d'identité de ce pair pour le moment — le mode Demander est activé, mais il n'a pas été ajouté à la liste d'autorisation. Réessayez après l'arrivée d'un chemin ou d'une annonce.", - "lxmfBody": "Veuillez activer la réception de fichiers (rncp) si vous utilisez mesh-client : Remote → Settings → Inbound file offers. Ou exécutez rncp/rncp-rs en mode écoute." + "lxmfBody": "Veuillez activer la réception de fichiers (rncp) si vous utilisez mesh-client : Remote → Settings → Inbound file offers. Ou exécutez rncp/rncp-rs en mode écoute.", + "lxmfShareBody": "La réception de fichiers est activée. Voici ma destination de réception rncp (mesh-client l'enregistrera pour vous)." }, "saved": { "labelPlaceholder": "Dénomination", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index a26a18453..81a31e3cb 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -594,7 +594,8 @@ "sendFailed": "Gagal mengirim: {{error}}", "newOfferToast": "{{peer}} ingin mengirimi Anda file: {{file}}", "requestEnable": "Minta pengaktifan", - "requestEnableAria": "Minta rekan ini untuk mengaktifkan penerimaan file" + "requestEnableAria": "Minta rekan ini untuk mengaktifkan penerimaan file", + "destinationHelp": "Bukan hash Chat/LXMF mereka. Gunakan Request enable — peer mesh-client membagikan ini secara otomatis setelah mereka menerima. Atau tempel dari Remote → My rncp receive destination." }, "shareLocation": "Bagikan lokasi", "shareLocationLabel": "📍 Lokasi bersama", @@ -4777,7 +4778,8 @@ "offerToast": "Penawaran file masuk: {{file}}", "copyInstructionsAria": "Salin instruksi untuk mengaktifkan penerimaan file", "copyInstructions": "Salin instruksi", - "instructionsCopied": "Instruksi disalin." + "instructionsCopied": "Instruksi disalin.", + "receiveDestSharedToast": "Destinasi penerimaan rncp {{peer}} disimpan — Anda dapat mengirim file sekarang." }, "enableRequest": { "title": "Aktifkan penerimaan file?", @@ -4794,7 +4796,8 @@ "enableFailed": "Tidak dapat mengaktifkan penerimaan file: {{error}}", "saveDirRequired": "Pilih folder penyimpanan untuk mengaktifkan penerimaan.", "identityUnknown": "Hash identitas peer ini belum dapat ditentukan — mode Tanya untuk file masuk sudah aktif, tetapi peer belum ditambahkan ke daftar izin. Coba lagi setelah jalur atau pengumuman diterima.", - "lxmfBody": "Harap aktifkan penerimaan file (rncp) jika Anda menggunakan mesh-client: Remote → Settings → Inbound file offers. Atau jalankan rncp/rncp-rs dalam mode mendengarkan." + "lxmfBody": "Harap aktifkan penerimaan file (rncp) jika Anda menggunakan mesh-client: Remote → Settings → Inbound file offers. Atau jalankan rncp/rncp-rs dalam mode mendengarkan.", + "lxmfShareBody": "Penerimaan file diaktifkan. Ini tujuan penerimaan rncp saya (mesh-client akan menyimpannya untuk Anda)." }, "saved": { "labelPlaceholder": "Label", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 9df3bc52f..b82849406 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -593,7 +593,8 @@ "sendFailed": "Invio non riuscito: {{error}}", "newOfferToast": "{{peer}} vuole inviarle un file: {{file}}", "requestEnable": "Richiesta abilitazione", - "requestEnableAria": "Chiedi a questo peer di abilitare la ricezione dei file" + "requestEnableAria": "Chiedi a questo peer di abilitare la ricezione dei file", + "destinationHelp": "Non il loro hash Chat/LXMF. Usa Request enable: i peer mesh-client lo condividono automaticamente dopo l'accettazione. Oppure incolla da Remote → My rncp receive destination." }, "shareLocation": "Condividi posizione", "shareLocationLabel": "📍Posizione condivisa", @@ -4777,7 +4778,8 @@ "offerToast": "Offerta file in arrivo: {{file}}", "copyInstructionsAria": "Copia le istruzioni per abilitare la ricezione del file", "copyInstructions": "Copia istruzioni", - "instructionsCopied": "Istruzioni copiate." + "instructionsCopied": "Istruzioni copiate.", + "receiveDestSharedToast": "Destinazione di ricezione rncp di {{peer}} salvata: ora puoi inviare un file." }, "enableRequest": { "title": "Abilitare la ricezione dei file?", @@ -4794,7 +4796,8 @@ "enableFailed": "Impossibile abilitare la ricezione dei file: {{error}}", "saveDirRequired": "Scegli una cartella di salvataggio per abilitare la ricezione.", "identityUnknown": "Non è stato ancora possibile risolvere l'hash di identità di questo peer — la modalità Chiedi è attiva, ma il peer non è stato aggiunto alla lista dei consentiti. Riprova dopo l'arrivo di un percorso o di un annuncio.", - "lxmfBody": "Abilitare la ricezione dei file (rncp) se si utilizzano le offerte di file mesh-client: Remote → Settings → Inbound. Oppure esegui rncp/rncp-rs in modalità di ascolto." + "lxmfBody": "Abilitare la ricezione dei file (rncp) se si utilizzano le offerte di file mesh-client: Remote → Settings → Inbound. Oppure esegui rncp/rncp-rs in modalità di ascolto.", + "lxmfShareBody": "La ricezione file è abilitata. Ecco la mia destinazione di ricezione rncp (mesh-client la salverà per te)." }, "saved": { "labelPlaceholder": "Label", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 55e1b4e0c..033dd6697 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -594,7 +594,8 @@ "sendFailed": "次の送信に失敗しました: {{error}}", "newOfferToast": "{{peer}}様からファイルのお送りをご希望です: {{file}}", "requestEnable": "リクエストの有効化", - "requestEnableAria": "このピアにファイル受信を有効にするように依頼します" + "requestEnableAria": "このピアにファイル受信を有効にするように依頼します", + "destinationHelp": "Chat/LXMFハッシュではありません。Request enable を使うと、mesh-client ピアが承認後に自動共有します。または Remote → My rncp receive destination から貼り付けてください。" }, "shareLocation": "位置情報を共有する", "shareLocationLabel": "📍 共有場所", @@ -4777,7 +4778,8 @@ "offerToast": "受信ファイルオファー: {{file}}", "copyInstructionsAria": "ファイル受信を有効にするための手順をコピーする", "copyInstructions": "手順をコピー", - "instructionsCopied": "手順をコピーしました。" + "instructionsCopied": "手順をコピーしました。", + "receiveDestSharedToast": "{{peer}} の rncp 受信先を保存しました — ファイルを送信できます。" }, "enableRequest": { "title": "ファイル受信を有効にしますか?", @@ -4794,7 +4796,8 @@ "enableFailed": "ファイル受信を有効にできませんでした: {{error}}", "saveDirRequired": "受信を有効にする保存フォルダを選択します。", "identityUnknown": "このピアのIDハッシュをまだ解決できませんでした。受信時の確認は有効になりましたが、許可リストには追加されていません。パスまたはアナウンスの到着後にもう一度お試しください。", - "lxmfBody": "mesh-clientをご利用の場合はファイル受信(rncp)を有効にしてください: Remote → Settings → Inbound file offers。またはリッスンモードで rncp/rncp-rs を実行してください。" + "lxmfBody": "mesh-clientをご利用の場合はファイル受信(rncp)を有効にしてください: Remote → Settings → Inbound file offers。またはリッスンモードで rncp/rncp-rs を実行してください。", + "lxmfShareBody": "ファイル受信が有効です。これが私の rncp 受信先です(mesh-client が保存します)。" }, "saved": { "labelPlaceholder": "ラベル", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 74b84a102..5c86525b2 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -594,7 +594,8 @@ "sendFailed": "전송 실패: {{error}}", "newOfferToast": "{{peer}}이(가) 당신에게 파일을 보내려고 합니다: {{file}}", "requestEnable": "활성화 요청", - "requestEnableAria": "이 피어에게 파일 수신을 활성화하도록 요청" + "requestEnableAria": "이 피어에게 파일 수신을 활성화하도록 요청", + "destinationHelp": "Chat/LXMF 해시가 아닙니다. Request enable을 사용하세요 — mesh-client 피어가 수락 후 자동으로 공유합니다. 또는 Remote → My rncp receive destination에서 붙여넣으세요." }, "shareLocation": "위치 공유", "shareLocationLabel": "📍 공유 위치", @@ -4777,7 +4778,8 @@ "offerToast": "수신 파일 제공: {{file}}", "copyInstructionsAria": "파일 수신 활성화를 위한 지침 복사", "copyInstructions": "지침 복사", - "instructionsCopied": "지침이 복사되었습니다." + "instructionsCopied": "지침이 복사되었습니다.", + "receiveDestSharedToast": "{{peer}}의 rncp 수신 대상을 저장했습니다 — 이제 파일을 보낼 수 있습니다." }, "enableRequest": { "title": "파일 수신을 활성화하시겠습니까?", @@ -4794,7 +4796,8 @@ "enableFailed": "파일 수신을 활성화할 수 없습니다: {{error}}", "saveDirRequired": "수신을 활성화하려면 저장 폴더를 선택하세요.", "identityUnknown": "이 피어의 신원 해시를 아직 확인할 수 없습니다 — 수신 확인은 활성화되었지만 허용 목록에는 추가되지 않았습니다. 경로나 알림이 도착한 후 다시 시도하세요.", - "lxmfBody": "mesh-client를 사용하는 경우 파일 수신(rncp)을 활성화하십시오: 원격 → 설정 → 인바운드 파일 제공. 또는 청취 모드에서 rncp/rncp-rs를 실행하십시오." + "lxmfBody": "mesh-client를 사용하는 경우 파일 수신(rncp)을 활성화하십시오: 원격 → 설정 → 인바운드 파일 제공. 또는 청취 모드에서 rncp/rncp-rs를 실행하십시오.", + "lxmfShareBody": "파일 수신이 활성화되었습니다. 제 rncp 수신 대상입니다(mesh-client가 저장합니다)." }, "saved": { "labelPlaceholder": "상표", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 7b62c2474..4428b7205 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -592,7 +592,8 @@ "newOfferToast": "{{peer}} wil je een bestand sturen: {{file}}", "requestEnable": "Aanvraag inschakelen", "requestEnableAria": "Vraag deze collega om bestandsontvangst in te schakelen", - "destinationLabel": "RNCP-bestemmingshash" + "destinationLabel": "RNCP-bestemmingshash", + "destinationHelp": "Niet hun Chat/LXMF-hash. Gebruik Request enable — mesh-client-peers delen dit automatisch na acceptatie. Of plak vanaf Remote → My rncp receive destination." }, "shareLocation": "Deel locatie", "shareLocationLabel": "📍 Gedeelde locatie", @@ -4777,7 +4778,8 @@ "offerToast": "Inkomende bestandsaanbieding: {{file}}", "copyInstructionsAria": "Kopieer instructies voor het inschakelen van bestandsontvangst", "copyInstructions": "Kopieer instructies", - "instructionsCopied": "Instructies gekopieerd." + "instructionsCopied": "Instructies gekopieerd.", + "receiveDestSharedToast": "rncp-ontvangstbestemming van {{peer}} opgeslagen — u kunt nu een bestand verzenden." }, "enableRequest": { "title": "Bestandsontvangst inschakelen?", @@ -4794,7 +4796,8 @@ "enableFailed": "Kon het ontvangen van bestanden niet inschakelen: {{error}}", "saveDirRequired": "Kies een opslagmap om ontvangst in te schakelen.", "identityUnknown": "De identiteitshash van deze peer kon nog niet worden bepaald — inkomend vragen is ingeschakeld, maar de peer is niet aan de toestaanlijst toegevoegd. Probeer het opnieuw nadat een pad of aankondiging is ontvangen.", - "lxmfBody": "Schakel bestandsontvangst (rncp) in als u mesh-client gebruikt: Extern → Instellingen → Inkomende bestandsaanbiedingen. Of voer rncp/rncp-rs uit in de luistermodus." + "lxmfBody": "Schakel bestandsontvangst (rncp) in als u mesh-client gebruikt: Extern → Instellingen → Inkomende bestandsaanbiedingen. Of voer rncp/rncp-rs uit in de luistermodus.", + "lxmfShareBody": "Bestandsontvangst is ingeschakeld. Dit is mijn rncp-ontvangstbestemming (mesh-client bewaart dit voor u)." }, "saved": { "labelPlaceholder": "Label", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index b3f94141e..c6c758af7 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -593,7 +593,8 @@ "sendFailed": "Nie udało się wysłać: {{error}}", "newOfferToast": "{{peer}} chce wysłać Ci plik: {{file}}", "requestEnable": "Włącz żądanie", - "requestEnableAria": "Poproś tego partnera, aby włączył odbieranie plików" + "requestEnableAria": "Poproś tego partnera, aby włączył odbieranie plików", + "destinationHelp": "To nie ich hash Chat/LXMF. Użyj Request enable — partnerzy mesh-client udostępniają go automatycznie po zaakceptowaniu. Lub wklej z Remote → My rncp receive destination." }, "shareLocation": "Udostępnij lokalizację", "shareLocationLabel": "📍 Udostępniona lokalizacja", @@ -4779,7 +4780,8 @@ "offerToast": "Oferta plików przychodzących: {{file}}", "copyInstructionsAria": "Kopiuj instrukcje dotyczące włączania odbierania plików", "copyInstructions": "Kopiuj instrukcje", - "instructionsCopied": "Skopiowano instrukcje." + "instructionsCopied": "Skopiowano instrukcje.", + "receiveDestSharedToast": "Zapisano miejsce docelowe odbioru rncp użytkownika {{peer}} — możesz teraz wysłać plik." }, "enableRequest": { "title": "Włączyć odbiór plików?", @@ -4796,7 +4798,8 @@ "enableFailed": "Nie można włączyć odbierania plików: {{error}}", "saveDirRequired": "Wybierz folder zapisu, aby włączyć odbiór.", "identityUnknown": "Nie udało się jeszcze ustalić skrótu tożsamości tego peera — tryb pytania jest włączony, ale nie dodano go do listy dozwolonych. Spróbuj ponownie, gdy nadejdzie ścieżka lub ogłoszenie.", - "lxmfBody": "Włącz odbieranie plików (rncp), jeśli używasz funkcji mesh-client: Remote → Settings → Inbound file offers. Lub uruchom rncp/rncp-rs w trybie słuchania." + "lxmfBody": "Włącz odbieranie plików (rncp), jeśli używasz funkcji mesh-client: Remote → Settings → Inbound file offers. Lub uruchom rncp/rncp-rs w trybie słuchania.", + "lxmfShareBody": "Odbieranie plików jest włączone. Oto moje miejsce docelowe odbioru rncp (mesh-client zapisze je dla Ciebie)." }, "saved": { "labelPlaceholder": "Oznaczenie", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 480fe25d9..b98a6e5cb 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -594,7 +594,8 @@ "sendFailed": "Falha ao enviar: {{error}}", "newOfferToast": "{{peer}} deseja enviar-lhe um arquivo: {{file}}", "requestEnable": "Solicitar ativação", - "requestEnableAria": "Peça a este colega para habilitar o recebimento de arquivos" + "requestEnableAria": "Peça a este colega para habilitar o recebimento de arquivos", + "destinationHelp": "Não é o hash Chat/LXMF deles. Use Request enable — pares mesh-client compartilham automaticamente após aceitar. Ou cole de Remote → My rncp receive destination." }, "shareLocation": "Compartilhar localização", "shareLocationLabel": "📍 Localização compartilhada", @@ -4777,7 +4778,8 @@ "offerToast": "Oferta de arquivo recebida: {{file}}", "copyInstructionsAria": "Copie as instruções para ativar o recebimento de arquivos", "copyInstructions": "Copiar instruções", - "instructionsCopied": "Instruções copiadas." + "instructionsCopied": "Instruções copiadas.", + "receiveDestSharedToast": "Destino de recebimento rncp de {{peer}} salvo — você já pode enviar um arquivo." }, "enableRequest": { "title": "Habilitar recebimento de arquivos?", @@ -4794,7 +4796,8 @@ "enableFailed": "Não foi possível ativar o recebimento de arquivos: {{error}}", "saveDirRequired": "Escolha uma pasta de salvamento para ativar o recebimento.", "identityUnknown": "Ainda não foi possível resolver o hash de identidade deste par — o modo Perguntar foi ativado, mas ele não foi adicionado à lista de permitidos. Tente novamente quando um caminho ou anúncio chegar.", - "lxmfBody": "Ative o recebimento de arquivos (rncp) se você usar mesh-client: Remote → Settings → Inbound file offers. Ou execute rncp/rncp-rs no modo de escuta." + "lxmfBody": "Ative o recebimento de arquivos (rncp) se você usar mesh-client: Remote → Settings → Inbound file offers. Ou execute rncp/rncp-rs no modo de escuta.", + "lxmfShareBody": "O recebimento de arquivos está ativado. Aqui está meu destino de recebimento rncp (mesh-client salvará para você)." }, "saved": { "labelPlaceholder": "Etiqueta", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index edfd5d5c8..5be4c301d 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -594,7 +594,8 @@ "sendFailed": "Не удалось отправить: {{error}}", "newOfferToast": "{{peer}} хочет отправить вам файл: {{file}}", "requestEnable": "Включить запрос", - "requestEnableAria": "Попросите этого однорангового узла включить получение файлов" + "requestEnableAria": "Попросите этого однорангового узла включить получение файлов", + "destinationHelp": "Не их хэш Chat/LXMF. Используйте Request enable — узлы mesh-client делятся этим автоматически после принятия. Или вставьте из Remote → My rncp receive destination." }, "shareLocation": "Поделиться местоположением", "shareLocationLabel": "📍 Общая локация", @@ -4779,7 +4780,8 @@ "offerToast": "Предложение по входящим файлам: {{file}}", "copyInstructionsAria": "Копировать инструкции для включения получения файла", "copyInstructions": "Копировать инструкции", - "instructionsCopied": "Инструкции скопированы." + "instructionsCopied": "Инструкции скопированы.", + "receiveDestSharedToast": "Сохранено rncp-назначение приёма {{peer}} — можно отправить файл." }, "enableRequest": { "title": "Включить получение файлов?", @@ -4796,7 +4798,8 @@ "enableFailed": "Не удалось включить получение файлов: {{error}}", "saveDirRequired": "Выберите папку сохранения, чтобы включить получение.", "identityUnknown": "Пока не удалось определить хеш личности этого пира — режим «Спрашивать» включён, но пир не добавлен в список разрешённых. Повторите попытку после получения пути или анонса.", - "lxmfBody": "Включите получение файлов (rncp), если вы используете mesh-client: Remote → Settings → Inbound file offers. Или запустите rncp/rncp-rs в режиме прослушивания." + "lxmfBody": "Включите получение файлов (rncp), если вы используете mesh-client: Remote → Settings → Inbound file offers. Или запустите rncp/rncp-rs в режиме прослушивания.", + "lxmfShareBody": "Приём файлов включён. Вот моё rncp-назначение приёма (mesh-client сохранит его для вас)." }, "saved": { "labelPlaceholder": "Ярлык", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 2a8196fee..1f10975d0 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -594,7 +594,8 @@ "sendFailed": "Gönderilemedi: {{error}}", "newOfferToast": "{{peer}} size bir dosya göndermek istiyor: {{file}}", "requestEnable": "Etkinleştirme isteği", - "requestEnableAria": "Bu eşten dosya almayı etkinleştirmesini isteyin" + "requestEnableAria": "Bu eşten dosya almayı etkinleştirmesini isteyin", + "destinationHelp": "Onların Chat/LXMF hash'i değil. Request enable kullanın — mesh-client eşleri kabul ettikten sonra otomatik paylaşır. Veya Remote → My rncp receive destination adresinden yapıştırın." }, "shareLocation": "Konumu paylaş", "shareLocationLabel": "📍 Paylaşılan konum", @@ -4777,7 +4778,8 @@ "offerToast": "Gelen dosya teklifi: {{file}}", "copyInstructionsAria": "Dosya almayı etkinleştirmek için talimatları kopyalama", "copyInstructions": "Talimatları kopyala", - "instructionsCopied": "Talimatlar kopyalandı." + "instructionsCopied": "Talimatlar kopyalandı.", + "receiveDestSharedToast": "{{peer}} için rncp alma hedefi kaydedildi — şimdi dosya gönderebilirsiniz." }, "enableRequest": { "title": "Dosya alımı etkinleştirilsin mi?", @@ -4794,7 +4796,8 @@ "enableFailed": "Dosya alımı etkinleştirilemedi: {{error}}", "saveDirRequired": "Alımı etkinleştirmek için bir kaydetme klasörü seçin.", "identityUnknown": "Bu eşin kimlik karması henüz çözülemedi — gelen Sor modu etkinleştirildi ancak eş izin listesine eklenmedi. Bir yol veya duyuru geldikten sonra tekrar deneyin.", - "lxmfBody": "Mesh-client kullanıyorsanız lütfen dosya almayı (rncp) etkinleştirin: Uzak → Ayarlar → Gelen dosya teklifleri. Veya rncp/rncp-rs'yi dinleme modunda çalıştırın." + "lxmfBody": "Mesh-client kullanıyorsanız lütfen dosya almayı (rncp) etkinleştirin: Uzak → Ayarlar → Gelen dosya teklifleri. Veya rncp/rncp-rs'yi dinleme modunda çalıştırın.", + "lxmfShareBody": "Dosya alma etkinleştirildi. İşte rncp alma hedefim (mesh-client sizin için kaydedecek)." }, "saved": { "labelPlaceholder": "Etiket", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 1b60d4fa8..03b71f90e 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -594,7 +594,8 @@ "sendFailed": "Не вдалося надіслати: {{error}}", "newOfferToast": "{{peer}} хоче надіслати вам файл: {{file}}", "requestEnable": "Увімкнути запит", - "requestEnableAria": "Попросіть цього вузла увімкнути отримання файлів" + "requestEnableAria": "Попросіть цього вузла увімкнути отримання файлів", + "destinationHelp": "Не їхній хеш Chat/LXMF. Використовуйте Request enable — вузли mesh-client діляться цим автоматично після прийняття. Або вставте з Remote → My rncp receive destination." }, "shareLocation": "Поділіться місцем розташування", "shareLocationLabel": "📍 Спільне розташування", @@ -4779,7 +4780,8 @@ "offerToast": "Пропозиція щодо вхідного файлу: {{file}}", "copyInstructionsAria": "Скопіювати інструкції для ввімкнення отримання файлів", "copyInstructions": "Копіювати інструкції", - "instructionsCopied": "Інструкції скопійовано." + "instructionsCopied": "Інструкції скопійовано.", + "receiveDestSharedToast": "Збережено rncp destination прийому {{peer}} — тепер можна надіслати файл." }, "enableRequest": { "title": "Увімкнути прийом файлів?", @@ -4796,7 +4798,8 @@ "enableFailed": "Не вдалося увімкнути отримання файлу: {{error}}", "saveDirRequired": "Виберіть папку збереження, щоб увімкнути отримання.", "identityUnknown": "Поки не вдалося визначити хеш особи цього піра — режим «Запитувати» увімкнено, але піра не додано до списку дозволених. Спробуйте ще раз після надходження шляху або анонсу.", - "lxmfBody": "Увімкніть отримання файлів (rncp), якщо ви використовуєте mesh-client: Remote → Settings → Inbound file offers. Або запустіть rncp/rncp-rs у режимі прослуховування." + "lxmfBody": "Увімкніть отримання файлів (rncp), якщо ви використовуєте mesh-client: Remote → Settings → Inbound file offers. Або запустіть rncp/rncp-rs у режимі прослуховування.", + "lxmfShareBody": "Прийом файлів увімкнено. Ось мій rncp destination прийому (mesh-client збереже його для вас)." }, "saved": { "labelPlaceholder": "Мітка", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 2b1aa8ab2..9055c655f 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -594,7 +594,8 @@ "sendFailed": "发送失败: {{error}}", "newOfferToast": "{{peer}}想向您发送文件: {{file}}", "requestEnable": "请求启用", - "requestEnableAria": "要求此同行启用文件接收" + "requestEnableAria": "要求此同行启用文件接收", + "destinationHelp": "不是他们的 Chat/LXMF 哈希。使用 Request enable — mesh-client 对等方在接受后会自动共享。或从 Remote → My rncp receive destination 粘贴。" }, "shareLocation": "分享位置", "shareLocationLabel": "📍 共享位置", @@ -4777,7 +4778,8 @@ "offerToast": "传入文件报价: {{file}}", "copyInstructionsAria": "复制启用文件接收的说明", "copyInstructions": "复制说明", - "instructionsCopied": "说明已复制。" + "instructionsCopied": "说明已复制。", + "receiveDestSharedToast": "已保存 {{peer}} 的 rncp 接收目的地 — 现在可以发送文件。" }, "enableRequest": { "title": "启用文件接收?", @@ -4794,7 +4796,8 @@ "enableFailed": "无法启用文件接收: {{error}}", "saveDirRequired": "选择一个保存文件夹以启用接收。", "identityUnknown": "暂时无法解析此对等节点的身份哈希 — 已启用来件询问,但未将其加入允许列表。等路径或公告到达后再试一次。", - "lxmfBody": "如果您使用mesh-client :远程→设置→入站文件提供,请启用文件接收( rncp )。或者在监听模式下运行rncp/rncp-rs。" + "lxmfBody": "如果您使用mesh-client :远程→设置→入站文件提供,请启用文件接收( rncp )。或者在监听模式下运行rncp/rncp-rs。", + "lxmfShareBody": "文件接收已启用。这是我的 rncp 接收目的地(mesh-client 将为您保存)。" }, "saved": { "labelPlaceholder": "标号", diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 4b456d347..e6799ae18 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -1,5 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { pushAppToast } from '@/renderer/components/Toast'; +import { + applyRncpReceiveDestShareFromLxmf, + rncpReceiveDestShareSavedToastMessage, +} from '@/renderer/lib/applyRncpReceiveDestShare'; import { isReticulumAutostartEnabled } from '@/renderer/lib/appSettingsStorage'; import { BatchedRingBufferAppender } from '@/renderer/lib/batchedRingBufferAppender'; import { requestChatOutboxDrain } from '@/renderer/lib/chatOutboxDrain'; @@ -92,7 +97,10 @@ import { } from '@/renderer/stores/reticulumIdentityStore'; import { useReticulumPropagationStore } from '@/renderer/stores/reticulumPropagationStore'; import type { ReticulumSidecarEvent, ReticulumWirePacketRow } from '@/shared/reticulum-types'; -import { lxmfBodyContainsRncpRequestEnable } from '@/shared/rncpRequestEnable'; +import { + lxmfBodyContainsRncpRequestEnable, + parseRncpReceiveDestShare, +} from '@/shared/rncpRequestEnable'; import { getIdentityIdForProtocol } from '../lib/identityByProtocol'; import { getOfflineIdentityIdForProtocol } from '../lib/offlineProtocolIdentities'; @@ -541,6 +549,17 @@ export function useReticulumRuntime(): ProtocolRuntime { receivedAt: Date.now(), }); } + if (p.direction !== 'outbound' && p.sender_hash && parseRncpReceiveDestShare(p.text)) { + const share = await applyRncpReceiveDestShareFromLxmf({ + senderHash: p.sender_hash, + senderName: p.sender_name, + text: p.text, + }); + if (share.ok) { + const peer = p.sender_name?.trim() || share.lxmfPeerHash.slice(0, 12); + pushAppToast(rncpReceiveDestShareSavedToastMessage(peer), 'success'); + } + } })(); }, [identityId, selfLxmfHash], diff --git a/src/shared/rncpRequestEnable.test.ts b/src/shared/rncpRequestEnable.test.ts index 6aa56677b..f3dbed0ab 100644 --- a/src/shared/rncpRequestEnable.test.ts +++ b/src/shared/rncpRequestEnable.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest'; import { + buildRncpReceiveDestShareBody, buildRncpRequestEnableMessageBody, lxmfBodyContainsRncpRequestEnable, + parseRncpReceiveDestShare, + RNCP_RECEIVE_DEST_SHARE_PREFIX, RNCP_REQUEST_ENABLE_SENTINEL, } from './rncpRequestEnable'; @@ -18,4 +21,21 @@ describe('rncpRequestEnable', () => { expect(lxmfBodyContainsRncpRequestEnable('ordinary chat')).toBe(false); expect(lxmfBodyContainsRncpRequestEnable(null)).toBe(false); }); + + it('builds and parses receive-dest share bodies', () => { + const hash = 'ab'.repeat(16); + const body = buildRncpReceiveDestShareBody('Here is my rncp receive destination.', hash); + expect(body).toContain(RNCP_RECEIVE_DEST_SHARE_PREFIX + hash); + expect(parseRncpReceiveDestShare(body)).toBe(hash); + }); + + it('parseRncpReceiveDestShare returns null for ordinary chat', () => { + expect(parseRncpReceiveDestShare('hello')).toBeNull(); + expect(parseRncpReceiveDestShare(null)).toBeNull(); + expect(parseRncpReceiveDestShare(`${RNCP_RECEIVE_DEST_SHARE_PREFIX}short`)).toBeNull(); + }); + + it('buildRncpReceiveDestShareBody rejects invalid hashes', () => { + expect(() => buildRncpReceiveDestShareBody('x', 'nope')).toThrow('invalid_rncp_receive_hash'); + }); }); diff --git a/src/shared/rncpRequestEnable.ts b/src/shared/rncpRequestEnable.ts index b9b87d7f3..70295685b 100644 --- a/src/shared/rncpRequestEnable.ts +++ b/src/shared/rncpRequestEnable.ts @@ -1,14 +1,19 @@ /** - * LXMF control sentinel for mesh-client "request rncp.receive enable". - * Ordinary LXMF DM body always includes human-readable instructions; mesh-client - * peers additionally parse this sentinel to open an enable modal. + * LXMF control sentinels for mesh-client rncp receive enable / dest sharing. + * Ordinary LXMF DM bodies always include human-readable instructions; mesh-client + * peers additionally parse these sentinels for UI automation. */ export const RNCP_REQUEST_ENABLE_SENTINEL = 'mesh-client:request-rncp-receive:v1'; +/** Prefix for replies that share the sender's rncp.receive destination hash. */ +export const RNCP_RECEIVE_DEST_SHARE_PREFIX = 'mesh-client:rncp-receive-dest:v1:'; + /** Rate-limit: one request per peer per this many ms. */ export const RNCP_REQUEST_ENABLE_COOLDOWN_MS = 10 * 60 * 1000; +const DEST_HASH_RE = /^[0-9a-f]{32}$/; + export function buildRncpRequestEnableMessageBody(instructions: string): string { const trimmed = instructions.trim(); return `${trimmed}\n\n${RNCP_REQUEST_ENABLE_SENTINEL}`; @@ -18,3 +23,29 @@ export function lxmfBodyContainsRncpRequestEnable(body: string | null | undefine if (!body) return false; return body.includes(RNCP_REQUEST_ENABLE_SENTINEL); } + +/** + * Build an LXMF body that shares this client's rncp.receive destination with a peer + * who requested enable (human line + machine-readable sentinel). + */ +export function buildRncpReceiveDestShareBody(instructions: string, receiveHash: string): string { + const hash = receiveHash.replace(/[^0-9a-f]/gi, '').toLowerCase(); + if (!DEST_HASH_RE.test(hash)) { + throw new Error('invalid_rncp_receive_hash'); + } + const trimmed = instructions.trim(); + return `${trimmed}\n\n${RNCP_RECEIVE_DEST_SHARE_PREFIX}${hash}`; +} + +/** + * Parse a peer's shared rncp.receive destination from an LXMF body, if present. + * Returns lowercase 32-hex or null. + */ +export function parseRncpReceiveDestShare(body: string | null | undefined): string | null { + if (!body) return null; + const idx = body.indexOf(RNCP_RECEIVE_DEST_SHARE_PREFIX); + if (idx < 0) return null; + const after = body.slice(idx + RNCP_RECEIVE_DEST_SHARE_PREFIX.length); + const candidate = after.replace(/[^0-9a-fA-F].*$/, '').toLowerCase(); + return DEST_HASH_RE.test(candidate) ? candidate : null; +} diff --git a/vitest.config.ts b/vitest.config.ts index 6fa559609..0e769f0b2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -134,6 +134,7 @@ const RENDERER_LOGIC_EXCLUDE = [ 'src/renderer/lib/reduceMotionPreference.test.ts', 'src/renderer/lib/remoteSettingsStorage.test.ts', 'src/renderer/lib/sendRncpRequestEnable.test.ts', + 'src/renderer/lib/applyRncpReceiveDestShare.test.ts', 'src/renderer/lib/pushRncpListenerPolicy.test.ts', 'src/renderer/lib/rfReconnectHelper.test.ts', 'src/renderer/lib/reticulum/useReticulumSidecarApi.test.ts', From b4dcc19c9e5b39b89673fb39f4a23c11e46d8edd Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 29 Jul 2026 15:05:47 -0600 Subject: [PATCH 03/21] feat(app): add setting to force 24-hour timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow App → Appearance to force wall-clock displays (chat, charts, etc.) to 24-hour format while leaving locale behavior when the toggle is off. --- src/renderer/components/AppPanel.tsx | 19 +++++++ src/renderer/components/ChatPanel.tsx | 8 +-- src/renderer/components/ConnectionPanel.tsx | 5 +- src/renderer/components/DiagnosticsPanel.tsx | 8 +-- src/renderer/components/LogAnalyzeModal.tsx | 4 +- src/renderer/components/NodeDetailModal.tsx | 7 ++- src/renderer/components/NodeInfoBody.tsx | 9 +++- src/renderer/components/ReticulumMapPanel.tsx | 7 ++- src/renderer/components/TelemetryPanel.tsx | 27 ++++------ src/renderer/components/rrc/RrcChatView.tsx | 9 ++-- src/renderer/hooks/useNodeStatusNotifier.ts | 12 ++++- src/renderer/lib/defaultAppSettings.ts | 2 + src/renderer/lib/formatDisplayTime.test.ts | 54 +++++++++++++++++++ src/renderer/lib/formatDisplayTime.ts | 47 ++++++++++++++++ src/renderer/lib/logAnalyzer.ts | 12 ++--- src/renderer/locales/cs/translation.json | 4 +- src/renderer/locales/de/translation.json | 4 +- src/renderer/locales/en/translation.json | 2 + src/renderer/locales/es/translation.json | 4 +- src/renderer/locales/fr/translation.json | 4 +- src/renderer/locales/id/translation.json | 4 +- src/renderer/locales/it/translation.json | 4 +- src/renderer/locales/ja/translation.json | 4 +- src/renderer/locales/ko/translation.json | 4 +- src/renderer/locales/nl/translation.json | 4 +- src/renderer/locales/pl/translation.json | 4 +- src/renderer/locales/pt-BR/translation.json | 4 +- src/renderer/locales/ru/translation.json | 4 +- src/renderer/locales/tr/translation.json | 4 +- src/renderer/locales/uk/translation.json | 4 +- src/renderer/locales/zh/translation.json | 4 +- src/renderer/stores/timeFormatStore.test.ts | 27 ++++++++++ src/renderer/stores/timeFormatStore.ts | 30 +++++++++++ 33 files changed, 286 insertions(+), 63 deletions(-) create mode 100644 src/renderer/lib/formatDisplayTime.test.ts create mode 100644 src/renderer/lib/formatDisplayTime.ts create mode 100644 src/renderer/stores/timeFormatStore.test.ts create mode 100644 src/renderer/stores/timeFormatStore.ts diff --git a/src/renderer/components/AppPanel.tsx b/src/renderer/components/AppPanel.tsx index ccb23dbdd..06a1e7fd5 100644 --- a/src/renderer/components/AppPanel.tsx +++ b/src/renderer/components/AppPanel.tsx @@ -49,6 +49,7 @@ import { useDiagnosticsStore } from '../stores/diagnosticsStore'; import { useNodeStore } from '../stores/nodeStore'; import { usePositionHistoryStore } from '../stores/positionHistoryStore'; import { useReticulumPeerStore } from '../stores/reticulumPeerStore'; +import { useTimeFormatStore } from '../stores/timeFormatStore'; import { ConfirmModal } from './ConfirmModal'; import { HelpTooltip } from './HelpTooltip'; import { ReticulumAppPanelSection } from './ReticulumAppPanelSection'; @@ -164,6 +165,7 @@ interface AppSettings { storeForwardHistoryProfile: 'conservative' | 'aggressive'; shareLocationSendWaypoint: boolean; reduceMotion: boolean; + use24HourTime: boolean; meshcoreOpenWireCompatEnabled: boolean; meshcorePathHashMode: 0 | 1 | 2; } @@ -1886,6 +1888,23 @@ export default function AppPanel({
+
+ { + updateSetting('use24HourTime', e.target.checked); + useTimeFormatStore.getState().setUse24HourTime(e.target.checked); + }} + aria-label={t('appPanel.use24HourTime')} + className="accent-brand-green" + /> + + +
{t('appPanel.colorScheme')} diff --git a/src/renderer/components/ChatPanel.tsx b/src/renderer/components/ChatPanel.tsx index 22a011bbd..f31f7ef4e 100644 --- a/src/renderer/components/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel.tsx @@ -38,6 +38,7 @@ import { import { useTranslation } from 'react-i18next'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { formatDisplayTime } from '@/renderer/lib/formatDisplayTime'; import { formatShortRelativeAgo } from '@/renderer/lib/formatShortRelativeAgo'; import { useIconTrigger, useParentIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; import { withMeshcoreFloodScopeOverride } from '@/renderer/lib/meshcoreFloodScopeSend'; @@ -144,6 +145,7 @@ import { import type { ChatMessage, MeshNode, MeshProtocol } from '../lib/types'; import type { RequestStoreForwardHistoryResult } from '../runtime/useMeshtasticRuntime'; import { reticulumHashForNodeId, useReticulumPeerStore } from '../stores/reticulumPeerStore'; +import { useTimeFormatStore } from '../stores/timeFormatStore'; import { ChatComposer, type ChatComposerSendOpts } from './ChatComposer'; import { ChatPayloadText } from './ChatPayloadText'; import { HelpTooltip } from './HelpTooltip'; @@ -547,6 +549,7 @@ function ChatPanel({ onSendLocationWaypoint, }: ChatPanelProps) { const { t } = useTranslation(); + const use24HourTime = useTimeFormatStore((s) => s.use24HourTime); const parentIconTrigger = useParentIconTrigger(); const { addToast } = useToast(); const ownNodeIdSet = useMemo(() => { @@ -1601,10 +1604,7 @@ function ChatPanel({ ); function formatTime(ts: number): string { - return new Date(ts).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - }); + return formatDisplayTime(ts, { use24Hour: use24HourTime }); } function formatFullTimestamp(ts: number): string { diff --git a/src/renderer/components/ConnectionPanel.tsx b/src/renderer/components/ConnectionPanel.tsx index e30e15210..94b5bcfae 100644 --- a/src/renderer/components/ConnectionPanel.tsx +++ b/src/renderer/components/ConnectionPanel.tsx @@ -5,6 +5,7 @@ import { createPortal } from 'react-dom'; import { Trans, useTranslation } from 'react-i18next'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { formatDisplayTime } from '@/renderer/lib/formatDisplayTime'; import { ConnectionIcon, MqttGlobeIcon } from '@/renderer/lib/icons/connectionIcons'; import { useParentIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; import { SpinnerIcon, SpinnerIconLg } from '@/renderer/lib/icons/spinnerIcon'; @@ -115,6 +116,7 @@ import type { SerialPortInfo, } from '../lib/types'; import { useDeviceStore } from '../stores/deviceStore'; +import { useTimeFormatStore } from '../stores/timeFormatStore'; import { ConfirmModal } from './ConfirmModal'; import ConnectionBatteryGauge from './ConnectionBatteryGauge'; import FirmwareStatusIndicator from './FirmwareStatusIndicator'; @@ -365,6 +367,7 @@ export default function ConnectionPanel({ const { t } = useTranslation(); const capabilities = useRadioProvider(protocol); const parentIconTrigger = useParentIconTrigger(); + const use24HourTime = useTimeFormatStore((s) => s.use24HourTime); const letsMeshUsernameSyncTimerRef = useRef | null>(null); const [reticulumStackError, setReticulumStackError] = useState(null); @@ -2993,7 +2996,7 @@ export default function ConnectionPanel({
{t('connectionPanel.lastData')} - {new Date(state.lastDataReceived).toLocaleTimeString()} + {formatDisplayTime(state.lastDataReceived, { use24Hour: use24HourTime })}
)} diff --git a/src/renderer/components/DiagnosticsPanel.tsx b/src/renderer/components/DiagnosticsPanel.tsx index e0df3fd8f..6d07bed63 100644 --- a/src/renderer/components/DiagnosticsPanel.tsx +++ b/src/renderer/components/DiagnosticsPanel.tsx @@ -13,6 +13,7 @@ import { } from 'recharts'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { formatDisplayTime } from '@/renderer/lib/formatDisplayTime'; import { formatRelativeOrIsoDate } from '@/renderer/lib/formatRelativeOrIsoDate'; import { useIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; import { SpinnerIcon } from '@/renderer/lib/icons/spinnerIcon'; @@ -22,6 +23,7 @@ import { isRfForeignLoraHeard, useDiagnosticsStore, } from '@/renderer/stores/diagnosticsStore'; +import { useTimeFormatStore } from '@/renderer/stores/timeFormatStore'; import { formatIsoDateTime } from '@/shared/formatIsoDate'; import { formatMeshtasticNodeId, meshtasticNodeIdMatchesHexQuery } from '@/shared/nodeNameUtils'; @@ -181,6 +183,7 @@ export default function DiagnosticsPanel({ onRefreshReticulumDiagnostics, }: Props) { const { t } = useTranslation(); + const use24HourTime = useTimeFormatStore((s) => s.use24HourTime); const formatRowTime = useCallback( (ts: number) => { if (!ts) return t('common.emDash'); @@ -939,10 +942,7 @@ export default function DiagnosticsPanel({ const chartData = samples .filter((s) => s.t >= cutoff) .map((s) => ({ - time: new Date(s.t).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - }), + time: formatDisplayTime(s.t, { use24Hour: use24HourTime }), cu: Math.round(s.cu * 10) / 10, })); if (chartData.length < 2) return null; diff --git a/src/renderer/components/LogAnalyzeModal.tsx b/src/renderer/components/LogAnalyzeModal.tsx index be04f6b27..4faccb753 100644 --- a/src/renderer/components/LogAnalyzeModal.tsx +++ b/src/renderer/components/LogAnalyzeModal.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useParentIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; +import { useTimeFormatStore } from '@/renderer/stores/timeFormatStore'; import { analyzeLogs, @@ -45,10 +46,11 @@ export default function LogAnalyzeModal({ }: LogAnalyzeModalProps) { const { t } = useTranslation(); const parentIconTrigger = useParentIconTrigger(); + const use24HourTime = useTimeFormatStore((s) => s.use24HourTime); const dialogRef = useRef(null); const result = analyzeLogs(entries, protocol); - const timeRange = formatTimeRange(result.oldestTs, result.newestTs); + const timeRange = formatTimeRange(result.oldestTs, result.newestTs, use24HourTime); const dedupedRecs = dedupeRecommendations(result.categories); useEffect(() => { diff --git a/src/renderer/components/NodeDetailModal.tsx b/src/renderer/components/NodeDetailModal.tsx index 91895ed1b..49b162627 100644 --- a/src/renderer/components/NodeDetailModal.tsx +++ b/src/renderer/components/NodeDetailModal.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { formatDisplayTime } from '@/renderer/lib/formatDisplayTime'; import { useParentIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; import { getIdentityIdForProtocol } from '@/renderer/lib/identityByProtocol'; import { @@ -64,6 +65,7 @@ import { useCoordFormatStore } from '../stores/coordFormatStore'; import { useDiagnosticsStore } from '../stores/diagnosticsStore'; import { useNodeStore } from '../stores/nodeStore'; import { usePathHistoryStore } from '../stores/pathHistoryStore'; +import { useTimeFormatStore } from '../stores/timeFormatStore'; import { useWatchedNodesStore } from '../stores/watchedNodesStore'; import { HelpTooltip } from './HelpTooltip'; import { MeshcoreRepeaterPasswordControls } from './MeshcoreRepeaterPasswordControls'; @@ -254,6 +256,7 @@ export default function NodeDetailModal({ }: NodeDetailModalProps) { const { t } = useTranslation(); const parentIconTrigger = useParentIconTrigger(); + const use24HourTime = useTimeFormatStore((s) => s.use24HourTime); const { ensureRepeaterAuth, promptRepeaterPassword, RemoteAuthModal } = useMeshcoreRepeaterRemoteAuth(); const { ensureRoomAuth, RemoteAuthModal: RoomAuthModal } = useMeshcoreRoomAuth(); @@ -935,7 +938,9 @@ export default function NodeDetailModal({
- {new Date(meshcoreNodeTelemetry.fetchedAt).toLocaleTimeString()} + {formatDisplayTime(meshcoreNodeTelemetry.fetchedAt, { + use24Hour: use24HourTime, + })}
)} @@ -783,7 +786,9 @@ export default function NodeInfoBody({ )} - {new Date(meshcoreTraceFirst.timestamp).toLocaleTimeString()} + {formatDisplayTime(meshcoreTraceFirst.timestamp, { + use24Hour: use24HourTime, + })} {meshcoreTraceHistory.length > 1 && ( {t('nodeInfoBody.olderCount', { count: meshcoreTraceHistory.length - 1 })} diff --git a/src/renderer/components/ReticulumMapPanel.tsx b/src/renderer/components/ReticulumMapPanel.tsx index 7850a6504..e2cf00cda 100644 --- a/src/renderer/components/ReticulumMapPanel.tsx +++ b/src/renderer/components/ReticulumMapPanel.tsx @@ -17,6 +17,7 @@ import { } from '@/renderer/components/map/leafletMapControls'; import { CHAT_SCROLL_END_THRESHOLD } from '@/renderer/lib/chatScrollUtils'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { formatDisplayDateTime } from '@/renderer/lib/formatDisplayTime'; import { readStoredStaticGps } from '@/renderer/lib/gpsSource'; import { DEFAULT_MAP_BASEMAP_ID, @@ -38,6 +39,7 @@ import { useMapLayerStore } from '@/renderer/stores/mapLayerStore'; import { useMapViewportStore } from '@/renderer/stores/mapViewportStore'; import { useReticulumDiscoveryMapStore } from '@/renderer/stores/reticulumDiscoveryMapStore'; import { useReticulumPeerStore } from '@/renderer/stores/reticulumPeerStore'; +import { useTimeFormatStore } from '@/renderer/stores/timeFormatStore'; const REFRESH_MS = 30_000; const DEFAULT_CENTER: [number, number] = [20, 0]; @@ -116,6 +118,7 @@ export default function ReticulumMapPanel({ onOpenAppGpsSettings, }: ReticulumMapPanelProps) { const { t } = useTranslation(); + const use24HourTime = useTimeFormatStore((s) => s.use24HourTime); const basemapId = useMapLayerStore((s) => s.basemapId); const basemap = MAP_BASEMAPS[basemapId] ?? MAP_BASEMAPS[DEFAULT_MAP_BASEMAP_ID]; const overlayColors = getMapOverlayColors(basemap.isDark); @@ -428,7 +431,9 @@ export default function ReticulumMapPanel({ )}
{t('reticulumMap.lastHeard', { - time: new Date(row.last_heard * 1000).toLocaleString(), + time: formatDisplayDateTime(row.last_heard * 1000, { + use24Hour: use24HourTime, + }), })}
diff --git a/src/renderer/components/TelemetryPanel.tsx b/src/renderer/components/TelemetryPanel.tsx index df5c6f91b..8dc772787 100644 --- a/src/renderer/components/TelemetryPanel.tsx +++ b/src/renderer/components/TelemetryPanel.tsx @@ -12,11 +12,13 @@ import { YAxis, } from 'recharts'; +import { formatDisplayTime } from '@/renderer/lib/formatDisplayTime'; import { useParentIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; import { downloadBlob } from '../lib/downloadBlob'; import type { ProtocolCapabilities } from '../lib/radio/BaseRadioProvider'; import type { EnvironmentTelemetryPoint, MeshCoreLocalStats, TelemetryPoint } from '../lib/types'; +import { useTimeFormatStore } from '../stores/timeFormatStore'; import RefreshButton from './RefreshButton'; import SignalMeter from './SignalMeter'; @@ -70,36 +72,29 @@ export default function TelemetryPanel({ }: Props) { const { t } = useTranslation(); const parentIconTrigger = useParentIconTrigger(); + const use24HourTime = useTimeFormatStore((s) => s.use24HourTime); const showEnvironment = capabilities?.hasEnvironmentTelemetry !== false; const showPacketStats = capabilities?.hasRfStats === true && meshcorePacketStats != null; const chartData = useMemo( () => telemetry.map((t, i) => ({ index: i, - time: new Date(t.timestamp).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }), + time: formatDisplayTime(t.timestamp, { withSeconds: true, use24Hour: use24HourTime }), battery: t.batteryLevel, voltage: t.voltage, })), - [telemetry], + [telemetry, use24HourTime], ); const signalChartData = useMemo( () => signalTelemetry.map((t, i) => ({ index: i, - time: new Date(t.timestamp).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }), + time: formatDisplayTime(t.timestamp, { withSeconds: true, use24Hour: use24HourTime }), snr: t.snr, rssi: t.rssi, })), - [signalTelemetry], + [signalTelemetry, use24HourTime], ); const hasBatteryData = chartData.some((d) => d.battery !== undefined || d.voltage !== undefined); @@ -109,11 +104,7 @@ export default function TelemetryPanel({ () => environmentTelemetry.map((t, i) => ({ index: i, - time: new Date(t.timestamp).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }), + time: formatDisplayTime(t.timestamp, { withSeconds: true, use24Hour: use24HourTime }), temperature: t.temperature !== undefined ? useFahrenheit @@ -130,7 +121,7 @@ export default function TelemetryPanel({ pressure: t.barometricPressure, iaq: t.iaq, })), - [environmentTelemetry, useFahrenheit], + [environmentTelemetry, useFahrenheit, use24HourTime], ); const hasTemp = envChartData.some((d) => d.temperature !== undefined); diff --git a/src/renderer/components/rrc/RrcChatView.tsx b/src/renderer/components/rrc/RrcChatView.tsx index ad2355408..7d5fd7aa4 100644 --- a/src/renderer/components/rrc/RrcChatView.tsx +++ b/src/renderer/components/rrc/RrcChatView.tsx @@ -3,7 +3,9 @@ import type { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { ChatPayloadText } from '@/renderer/components/ChatPayloadText'; +import { formatDisplayTime } from '@/renderer/lib/formatDisplayTime'; import { bodyMentionsRrcNick, findNextRrcNickMention } from '@/renderer/lib/rrcMention'; +import { useTimeFormatStore } from '@/renderer/stores/timeFormatStore'; import type { RrcChatMessage } from '@/shared/rrc-types'; function formatHash(hash: string): string { @@ -84,6 +86,7 @@ export function RrcChatView({ nickname = '', }: RrcChatViewProps) { const { t } = useTranslation(); + const use24HourTime = useTimeFormatStore((s) => s.use24HourTime); if (!connected) { return ( @@ -106,11 +109,7 @@ export function RrcChatView({ messages.map((msg) => { const nick = msg.nickname || (msg.sender_hash ? formatHash(msg.sender_hash) : ''); const time = showTimestamps - ? new Date(msg.timestamp).toLocaleTimeString(undefined, { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }) + ? formatDisplayTime(msg.timestamp, { withSeconds: true, use24Hour: use24HourTime }) : null; const lineClass = msg.kind === 'notice' || msg.kind === 'system' diff --git a/src/renderer/hooks/useNodeStatusNotifier.ts b/src/renderer/hooks/useNodeStatusNotifier.ts index 2bd6b9d1c..68e880eab 100644 --- a/src/renderer/hooks/useNodeStatusNotifier.ts +++ b/src/renderer/hooks/useNodeStatusNotifier.ts @@ -2,9 +2,11 @@ import { useEffect, useRef } from 'react'; import { formatMeshtasticNodeId } from '@/shared/nodeNameUtils'; +import { formatDisplayTime } from '../lib/formatDisplayTime'; import { getNodeStatus } from '../lib/nodeStatus'; import type { ProtocolCapabilities } from '../lib/radio/BaseRadioProvider'; import type { MeshNode } from '../lib/types'; +import { useTimeFormatStore } from '../stores/timeFormatStore'; import { useWatchedNodesStore } from '../stores/watchedNodesStore'; function computeIsOnline(node: MeshNode, capabilities: ProtocolCapabilities | null): boolean { @@ -36,6 +38,7 @@ export function useNodeStatusNotifier( capabilities: ProtocolCapabilities | null, ): void { const watchedNodeIds = useWatchedNodesStore((s) => s.watchedNodeIds); + const use24HourTime = useTimeFormatStore((s) => s.use24HourTime); const prevOnlineRef = useRef>(new Map()); useEffect(() => { @@ -64,13 +67,18 @@ export function useNodeStatusNotifier( if (!wasOnline && isOnline) { fireNotification(`${name} is online`, `${protocolLabel} node came online`); } else if (wasOnline && !isOnline) { + const lastHeardMs = node.last_heard + ? node.last_heard < 1e12 + ? node.last_heard * 1000 + : node.last_heard + : null; fireNotification( `${name} went offline`, - `Last heard: ${node.last_heard ? new Date(node.last_heard < 1e12 ? node.last_heard * 1000 : node.last_heard).toLocaleTimeString() : 'unknown'}`, + `Last heard: ${lastHeardMs != null ? formatDisplayTime(lastHeardMs, { use24Hour: use24HourTime }) : 'unknown'}`, ); } } prevOnlineRef.current = next; - }, [nodes, watchedNodeIds, capabilities]); + }, [nodes, watchedNodeIds, capabilities, use24HourTime]); } diff --git a/src/renderer/lib/defaultAppSettings.ts b/src/renderer/lib/defaultAppSettings.ts index 69aee4303..d98a5d81b 100644 --- a/src/renderer/lib/defaultAppSettings.ts +++ b/src/renderer/lib/defaultAppSettings.ts @@ -38,6 +38,8 @@ export const DEFAULT_APP_SETTINGS_SHARED = { chatCompactMode: false, /** When true, disables non-essential UI motion (animated icons, decorative pulses). */ reduceMotion: false, + /** When true, force wall-clock timestamps (chat, charts, etc.) to 24-hour format. */ + use24HourTime: false, /** Auto-request Store & Forward chat history on RF connect (with cap/cooldown). */ storeForwardAutoFetchHistory: true, /** diff --git a/src/renderer/lib/formatDisplayTime.test.ts b/src/renderer/lib/formatDisplayTime.test.ts new file mode 100644 index 000000000..e9ea12671 --- /dev/null +++ b/src/renderer/lib/formatDisplayTime.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatDisplayDateTime, + formatDisplayTime, + getDisplayTimeOptions, +} from './formatDisplayTime'; + +describe('getDisplayTimeOptions', () => { + it('omits hour12 when use24Hour is false or omitted', () => { + expect(getDisplayTimeOptions({ use24Hour: false })).toEqual({ + hour: '2-digit', + minute: '2-digit', + }); + expect(getDisplayTimeOptions()).not.toHaveProperty('hour12'); + }); + + it('sets hour12 false when use24Hour is true', () => { + expect(getDisplayTimeOptions({ use24Hour: true })).toEqual({ + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + }); + + it('includes seconds when withSeconds is true', () => { + expect(getDisplayTimeOptions({ use24Hour: true, withSeconds: true })).toEqual({ + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + }); +}); + +describe('formatDisplayTime', () => { + it('formats with explicit 24-hour preference', () => { + // 2024-01-15 15:30 local + const ts = new Date(2024, 0, 15, 15, 30, 0).getTime(); + const s = formatDisplayTime(ts, { use24Hour: true }); + expect(s).toMatch(/15:30/); + expect(s).not.toMatch(/PM|AM/i); + }); +}); + +describe('formatDisplayDateTime', () => { + it('includes a date portion and respects 24-hour preference', () => { + const ts = new Date(2024, 0, 15, 15, 30, 0).getTime(); + const s = formatDisplayDateTime(ts, { use24Hour: true }); + expect(s).toMatch(/15/); + expect(s).toMatch(/30/); + expect(s).not.toMatch(/PM|AM/i); + }); +}); diff --git a/src/renderer/lib/formatDisplayTime.ts b/src/renderer/lib/formatDisplayTime.ts new file mode 100644 index 000000000..3935a4c57 --- /dev/null +++ b/src/renderer/lib/formatDisplayTime.ts @@ -0,0 +1,47 @@ +export interface DisplayTimeOptionsInput { + /** Include seconds in the time portion. */ + withSeconds?: boolean; + /** + * When true, force 24-hour clocks (`hour12: false`). + * When false/omitted, omit `hour12` so the OS/locale decides. + */ + use24Hour?: boolean; +} + +/** Options for `Date#toLocaleTimeString` / `toLocaleString` honoring the 24h preference. */ +export function getDisplayTimeOptions( + input: DisplayTimeOptionsInput = {}, +): Intl.DateTimeFormatOptions { + const opts: Intl.DateTimeFormatOptions = { + hour: '2-digit', + minute: '2-digit', + }; + if (input.withSeconds) { + opts.second = '2-digit'; + } + if (input.use24Hour === true) { + opts.hour12 = false; + } + return opts; +} + +function toDate(ts: number | Date): Date { + return typeof ts === 'number' ? new Date(ts) : ts; +} + +/** Locale wall-clock time (hour + minute; optional seconds). */ +export function formatDisplayTime(ts: number | Date, input: DisplayTimeOptionsInput = {}): string { + return toDate(ts).toLocaleTimeString([], getDisplayTimeOptions(input)); +} + +/** Locale date + time (for map-style full timestamps). */ +export function formatDisplayDateTime( + ts: number | Date, + input: Omit = {}, +): string { + const opts: Intl.DateTimeFormatOptions = {}; + if (input.use24Hour === true) { + opts.hour12 = false; + } + return toDate(ts).toLocaleString(undefined, opts); +} diff --git a/src/renderer/lib/logAnalyzer.ts b/src/renderer/lib/logAnalyzer.ts index 685b756c9..7ee35bef4 100644 --- a/src/renderer/lib/logAnalyzer.ts +++ b/src/renderer/lib/logAnalyzer.ts @@ -1,4 +1,5 @@ import { formatIsoDate } from '../../shared/formatIsoDate'; +import { formatDisplayTime } from './formatDisplayTime'; import type { MeshProtocol } from './types'; export interface LogEntry { @@ -393,15 +394,8 @@ export function analyzeLogs(entries: LogEntry[], protocol: MeshProtocol): Analys }; } -export function formatTimeRange(oldestTs: number, newestTs: number): string { - const format = (ts: number) => { - const d = new Date(ts); - return d.toLocaleTimeString(undefined, { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }); - }; +export function formatTimeRange(oldestTs: number, newestTs: number, use24Hour = false): string { + const format = (ts: number) => formatDisplayTime(ts, { withSeconds: true, use24Hour }); const oldestDate = new Date(oldestTs).toDateString(); const newestDate = new Date(newestTs).toDateString(); diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 55164e127..60d8d3a1f 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -364,7 +364,9 @@ "capStoredRrcMessages": "Omezit uloženou historii místností RRC, uchovat nejnovější", "capStoredRrcMessagesLabel": "Omezit uloženou historii místností RRC, uchovat nejnovější", "capStoredRrcMessagesCountAria": "Omezit uloženou historii místností RRC, ponechat nejnovější zprávy {{count}}", - "reticulumPropagationHelp": "Nakonfigurujte vzdálené uzly šíření LXMF pro offline DM. Místní propagace je pouze schránka tohoto zařízení — nenahrazuje vzdálený uzel." + "reticulumPropagationHelp": "Nakonfigurujte vzdálené uzly šíření LXMF pro offline DM. Místní propagace je pouze schránka tohoto zařízení — nenahrazuje vzdálený uzel.", + "use24HourTime": "24hodinový čas", + "use24HourTimeDesc": "Vynutit hodiny, jako jsou časová razítka chatu, do 24hodinového formátu. Když je vypnuto, postupuje podle místního nastavení systému." }, "aria": { "closeDialog": "Zavřít dialog", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index d79d740a0..b908f1c8c 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -364,7 +364,9 @@ "capStoredRrcMessages": "Gespeicherte RRC-Raumhistorie begrenzen, die neuesten behalten", "capStoredRrcMessagesLabel": "Gespeicherte RRC-Raumhistorie begrenzen, die neuesten behalten", "capStoredRrcMessagesCountAria": "Begrenzen Sie den gespeicherten RRC-Raumverlauf und behalten Sie die neuesten {{count}}-Nachrichten bei", - "reticulumPropagationHelp": "Konfigurieren Sie Remote-LXMF-Verbreitungsknoten für Offline-DMS. Die lokale Ausbreitung ist nur der Posteingang dieses Geräts — sie ersetzt keinen Remote-Knoten." + "reticulumPropagationHelp": "Konfigurieren Sie Remote-LXMF-Verbreitungsknoten für Offline-DMS. Die lokale Ausbreitung ist nur der Posteingang dieses Geräts — sie ersetzt keinen Remote-Knoten.", + "use24HourTime": "24-Stunden-Zeitformat", + "use24HourTimeDesc": "Erzwinge Uhren wie Chat-Zeitstempel im 24-Stunden-Format. Wenn diese Option deaktiviert ist, folgt sie Ihrem Systemgebietsschema." }, "aria": { "closeDialog": "Schließen Dialog", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 051778f5f..72510665b 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -252,6 +252,8 @@ "compactMessages": "Compact message view", "reduceMotion": "Reduce motion", "reduceMotionDesc": "Disables animated icons and decorative effects. Loading spinners and connection status indicators still animate.", + "use24HourTime": "Use 24-hour time", + "use24HourTimeDesc": "Force clocks like chat timestamps to 24-hour format. When off, follows your system locale.", "storeForwardAutoFetchHistory": "Automatically fetch Store & Forward chat history on connect", "storeForwardAutoFetchHistoryHint": "On RF connect, request chat history with the selected catch-up profile. Turn off if your gateway MQTT bridge is overloaded.", "storeForwardHistoryProfileLabel": "Store & Forward catch-up profile", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 7c806b9cf..69922cd83 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -364,7 +364,9 @@ "capStoredRrcMessages": "Limitar el historial de sala RRC almacenado, mantener el más reciente", "capStoredRrcMessagesLabel": "Limitar el historial de sala RRC almacenado, mantener el más reciente", "capStoredRrcMessagesCountAria": "Limitar el historial de sala RRC almacenado, conservar los mensajes {{count}} más recientes", - "reticulumPropagationHelp": "Configurar nodos de propagación LXMF remotos para DM sin conexión. La propagación local es solo la bandeja de entrada de este dispositivo: no reemplaza a un nodo remoto." + "reticulumPropagationHelp": "Configurar nodos de propagación LXMF remotos para DM sin conexión. La propagación local es solo la bandeja de entrada de este dispositivo: no reemplaza a un nodo remoto.", + "use24HourTime": "Modo 24 horas", + "use24HourTimeDesc": "Forza los relojes, como las marcas de tiempo del chat, al formato de 24 horas. Cuando esté apagado, siga la configuración regional de su sistema." }, "aria": { "closeDialog": "Cerrar cuadro de diálogo", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 17ce1edce..0c8c626d1 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -364,7 +364,9 @@ "capStoredRrcMessages": "Limiter l'historique des salles RRC stockées, conserver les plus récentes", "capStoredRrcMessagesLabel": "Limiter l'historique des salles RRC stockées, conserver les plus récentes", "capStoredRrcMessagesCountAria": "Limiter l'historique des salles RRC stockées, conserver les messages {{count}} les plus récents", - "reticulumPropagationHelp": "Configurez les nœuds de propagation LXMF distants pour les DM hors ligne. La propagation locale est la boîte de réception de cet appareil uniquement — elle ne remplace pas un nœud distant." + "reticulumPropagationHelp": "Configurez les nœuds de propagation LXMF distants pour les DM hors ligne. La propagation locale est la boîte de réception de cet appareil uniquement — elle ne remplace pas un nœud distant.", + "use24HourTime": "Heure (format 24 heures)", + "use24HourTimeDesc": "Forcer les horloges comme les horodatages de chat au format 24 heures. Lorsqu'il est désactivé, suit les paramètres régionaux de votre système." }, "aria": { "closeDialog": "Fermer la boîte de dialogue", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 81a31e3cb..3022720f6 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -364,7 +364,9 @@ "capStoredRrcMessages": "Batasi riwayat ruang RRC yang disimpan, simpan yang terbaru", "capStoredRrcMessagesLabel": "Batasi riwayat ruang RRC yang disimpan, simpan yang terbaru", "capStoredRrcMessagesCountAria": "Batasi riwayat ruang RRC yang disimpan, simpan pesan {{count}} terbaru", - "reticulumPropagationHelp": "Konfigurasikan node propagasi LXMF jarak jauh untuk DM offline. Propagasi lokal adalah kotak masuk perangkat ini saja — tidak menggantikan node jarak jauh." + "reticulumPropagationHelp": "Konfigurasikan node propagasi LXMF jarak jauh untuk DM offline. Propagasi lokal adalah kotak masuk perangkat ini saja — tidak menggantikan node jarak jauh.", + "use24HourTime": "Gunakan waktu 24 jam", + "use24HourTimeDesc": "Paksa jam seperti stempel waktu obrolan ke format 24 jam. Saat mati, ikuti lokal sistem Anda." }, "aria": { "closeDialog": "Tutup dialog", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index b82849406..f13694753 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -364,7 +364,9 @@ "capStoredRrcMessages": "Limita la cronologia delle stanze RRC memorizzata e mantieni le più recenti", "capStoredRrcMessagesLabel": "Limita la cronologia delle stanze RRC memorizzata e mantieni le più recenti", "capStoredRrcMessagesCountAria": "Limita la cronologia delle stanze RRC memorizzata e conserva i messaggi {{count}} più recenti", - "reticulumPropagationHelp": "Configurare nodi di propagazione LXMF remoti per DM offline. La propagazione locale è solo la casella di posta in arrivo di questo dispositivo — non sostituisce un nodo remoto." + "reticulumPropagationHelp": "Configurare nodi di propagazione LXMF remoti per DM offline. La propagazione locale è solo la casella di posta in arrivo di questo dispositivo — non sostituisce un nodo remoto.", + "use24HourTime": "Formato 24 ore", + "use24HourTimeDesc": "Forza gli orologi come i timestamp della chat al formato di 24 ore. Quando è spento, seguire le impostazioni locali del sistema." }, "aria": { "closeDialog": "Chiudi finestra", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 033dd6697..6fba8c659 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -364,7 +364,9 @@ "capStoredRrcMessages": "保存される RRC ルーム履歴に上限を設け、最新の状態に保つ", "capStoredRrcMessagesLabel": "保存される RRC ルーム履歴に上限を設け、最新の状態に保つ", "capStoredRrcMessagesCountAria": "保存されている RRC ルーム履歴を制限し、最新の {{count}} メッセージを保持します", - "reticulumPropagationHelp": "オフラインDMのリモートLXMF伝播ノードを設定します。ローカル伝播は、このデバイスの受信トレイのみであり、リモートノードを置き換えるものではありません。" + "reticulumPropagationHelp": "オフラインDMのリモートLXMF伝播ノードを設定します。ローカル伝播は、このデバイスの受信トレイのみであり、リモートノードを置き換えるものではありません。", + "use24HourTime": "24時間制を使用する", + "use24HourTimeDesc": "チャットタイムスタンプなどのクロックを24時間形式に強制します。オフの場合、システムのロケールに従います。" }, "aria": { "closeDialog": "ダイアログを閉じる", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 5c86525b2..5bb5c353d 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -364,7 +364,9 @@ "capStoredRrcMessages": "RRC 방 기록을 저장하고 최신 상태로 유지합니다.", "capStoredRrcMessagesLabel": "RRC 방 기록을 저장하고 최신 상태로 유지합니다.", "capStoredRrcMessagesCountAria": "저장된 RRC 방 기록을 제한하고 최신 {{count}} 메시지를 유지합니다.", - "reticulumPropagationHelp": "오프라인 DM에 대한 원격 LXMF 전파 노드를 구성합니다. 로컬 전파는 이 장치의 받은 편지함일 뿐이며 원격 노드를 대체하지 않습니다." + "reticulumPropagationHelp": "오프라인 DM에 대한 원격 LXMF 전파 노드를 구성합니다. 로컬 전파는 이 장치의 받은 편지함일 뿐이며 원격 노드를 대체하지 않습니다.", + "use24HourTime": "24시간 사용", + "use24HourTimeDesc": "채팅 타임스탬프와 같은 시계를 24시간 형식으로 강제 설정합니다. 꺼지면 시스템 로케일을 따릅니다." }, "aria": { "closeDialog": "대화상자 닫기", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 4428b7205..3874ef544 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -364,7 +364,9 @@ "capStoredRrcMessages": "Beperk opgeslagen RRC-ruimtegeschiedenis, bewaar de nieuwste", "capStoredRrcMessagesLabel": "Beperk opgeslagen RRC-ruimtegeschiedenis, bewaar de nieuwste", "capStoredRrcMessagesCountAria": "Beperk de opgeslagen RRC-ruimtegeschiedenis, bewaar de nieuwste {{count}}-berichten", - "reticulumPropagationHelp": "Configureer externe LXMF-voortplantingsknooppunten voor offline DM's. Lokale verspreiding is alleen de inbox van dit apparaat — het vervangt geen extern knooppunt." + "reticulumPropagationHelp": "Configureer externe LXMF-voortplantingsknooppunten voor offline DM's. Lokale verspreiding is alleen de inbox van dit apparaat — het vervangt geen extern knooppunt.", + "use24HourTime": "24-uursaanduiding", + "use24HourTimeDesc": "Forceer klokken zoals chattijdstempels naar 24-uurs formaat. Als deze optie is uitgeschakeld, volgt u de landinstelling van uw systeem." }, "aria": { "closeDialog": "Dialoogvenster sluiten", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index c6c758af7..54c2dcebe 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -364,7 +364,9 @@ "capStoredRrcMessages": "Ogranicz zapisaną historię pokoi RRC, zachowaj najnowsze", "capStoredRrcMessagesLabel": "Ogranicz zapisaną historię pokoi RRC, zachowaj najnowsze", "capStoredRrcMessagesCountAria": "Ogranicz zapisaną historię pokoju RRC, zachowaj najnowsze wiadomości {{count}}", - "reticulumPropagationHelp": "Skonfiguruj zdalne węzły propagacji LXMF dla wiadomości DM offline. Lokalna propagacja to tylko skrzynka odbiorcza tego urządzenia — nie zastępuje zdalnego węzła." + "reticulumPropagationHelp": "Skonfiguruj zdalne węzły propagacji LXMF dla wiadomości DM offline. Lokalna propagacja to tylko skrzynka odbiorcza tego urządzenia — nie zastępuje zdalnego węzła.", + "use24HourTime": "Format 24-godzinny", + "use24HourTimeDesc": "Wymuś wyświetlanie zegarów, takich jak znaczniki czasu czatu, w formacie 24-godzinnym. Gdy jest wyłączony, postępuje zgodnie z ustawieniami regionalnymi systemu." }, "aria": { "closeDialog": "Zamknij okno dialogowe", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index b98a6e5cb..6ca3c2740 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -364,7 +364,9 @@ "capStoredRrcMessages": "Limite o histórico da sala RRC armazenado, mantenha o mais recente", "capStoredRrcMessagesLabel": "Limite o histórico da sala RRC armazenado, mantenha o mais recente", "capStoredRrcMessagesCountAria": "Limite o histórico da sala RRC armazenado, mantenha as mensagens {{count}} mais recentes", - "reticulumPropagationHelp": "Configurar nós de propagação LXMF remotos para DMs offline. A propagação local é apenas a caixa de entrada deste dispositivo — ela não substitui um nó remoto." + "reticulumPropagationHelp": "Configurar nós de propagação LXMF remotos para DMs offline. A propagação local é apenas a caixa de entrada deste dispositivo — ela não substitui um nó remoto.", + "use24HourTime": "Use o tempo de 24 horas", + "use24HourTimeDesc": "Forçar relógios como carimbos de data e hora de bate-papo para o formato de 24 horas. Quando desligado, segue a localidade do seu sistema." }, "aria": { "closeDialog": "Fechar janela", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 5be4c301d..8cd70f220 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -364,7 +364,9 @@ "capStoredRrcMessages": "Ограничить сохранённую историю комнат RRC, хранить новейшие", "capStoredRrcMessagesLabel": "Ограничить сохранённую историю комнат RRC, хранить новейшие", "capStoredRrcMessagesCountAria": "Ограничить сохраненную историю комнат RRC, сохранять новейшие сообщения {{count}}", - "reticulumPropagationHelp": "Настройте удаленные узлы распространения LXMF для автономных DM. Локальное распространение - это только почтовый ящик этого устройства — он не заменяет удаленный узел." + "reticulumPropagationHelp": "Настройте удаленные узлы распространения LXMF для автономных DM. Локальное распространение - это только почтовый ящик этого устройства — он не заменяет удаленный узел.", + "use24HourTime": "24-часовой формат времени", + "use24HourTimeDesc": "Принудительно переведите часы, такие как метки времени чата, в 24-часовой формат. При выключении следует за локалью системы." }, "aria": { "closeDialog": "Закрыть диалог", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 1f10975d0..eb43feb06 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -364,7 +364,9 @@ "capStoredRrcMessages": "Saklanan RRC oda geçmişini sınırlayın, en yeniyi koruyun", "capStoredRrcMessagesLabel": "Saklanan RRC oda geçmişini sınırlayın, en yeniyi koruyun", "capStoredRrcMessagesCountAria": "Saklanan RRC oda geçmişini sınırlayın, en yeni {{count}} mesajlarını saklayın", - "reticulumPropagationHelp": "Çevrimdışı DM'ler için uzak LXMF yayılım düğümlerini yapılandırın. Yerel yayılım yalnızca bu cihazın gelen kutusudur — uzak bir düğümün yerini almaz." + "reticulumPropagationHelp": "Çevrimdışı DM'ler için uzak LXMF yayılım düğümlerini yapılandırın. Yerel yayılım yalnızca bu cihazın gelen kutusudur — uzak bir düğümün yerini almaz.", + "use24HourTime": "24-Saat Zaman", + "use24HourTimeDesc": "Sohbet zaman damgaları gibi saatleri 24 saat biçimine zorlayın. Kapalıyken, sisteminizin yerel ayarını takip edin." }, "aria": { "closeDialog": "İletişim kutusunu kapat", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 03b71f90e..d73199b24 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -364,7 +364,9 @@ "capStoredRrcMessages": "Обмежити збережену історію кімнат RRC, зберігати найновіші", "capStoredRrcMessagesLabel": "Обмежити збережену історію кімнат RRC, зберігати найновіші", "capStoredRrcMessagesCountAria": "Обмежити збережену історію кімнат RRC, зберігати найновіші {{count}} повідомлення", - "reticulumPropagationHelp": "Налаштуйте віддалені вузли розповсюдження LXMF для автономних DM. Локальне поширення - це лише папка «Вхідні» цього пристрою — вона не замінює віддалений вузол." + "reticulumPropagationHelp": "Налаштуйте віддалені вузли розповсюдження LXMF для автономних DM. Локальне поширення - це лише папка «Вхідні» цього пристрою — вона не замінює віддалений вузол.", + "use24HourTime": "Використовувати 24-годинний час", + "use24HourTimeDesc": "Примусово перевести годинник, як-от чат, у 24-годинний формат. Коли вимкнено, слідкуйте за локаллю системи." }, "aria": { "closeDialog": "Закрити діалог", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 9055c655f..ade30a41c 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -364,7 +364,9 @@ "capStoredRrcMessages": "存储RRC房间历史上限,保持最新", "capStoredRrcMessagesLabel": "存储RRC房间历史上限,保持最新", "capStoredRrcMessagesCountAria": "限制存储的 RRC 房间历史记录,保留最新的 {{count}} 消息", - "reticulumPropagationHelp": "为离线DM配置远程LXMF传播节点。本地传播仅是此设备的收件箱—它不会替换远程节点。" + "reticulumPropagationHelp": "为离线DM配置远程LXMF传播节点。本地传播仅是此设备的收件箱—它不会替换远程节点。", + "use24HourTime": "24小时时间", + "use24HourTimeDesc": "强制将聊天时间戳等时钟设置为24小时格式。关闭时,请遵循您的系统区域设置。" }, "aria": { "closeDialog": "关闭对话框", diff --git a/src/renderer/stores/timeFormatStore.test.ts b/src/renderer/stores/timeFormatStore.test.ts new file mode 100644 index 000000000..9eb5d7b61 --- /dev/null +++ b/src/renderer/stores/timeFormatStore.test.ts @@ -0,0 +1,27 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../lib/appSettingsStorage', () => ({ + getAppSettingsRaw: vi.fn(() => null), + mergeAppSetting: vi.fn(), +})); + +import { getAppSettingsRaw, mergeAppSetting } from '../lib/appSettingsStorage'; +import { useTimeFormatStore } from './timeFormatStore'; + +describe('timeFormatStore', () => { + beforeEach(() => { + vi.mocked(getAppSettingsRaw).mockReturnValue(null); + vi.mocked(mergeAppSetting).mockClear(); + useTimeFormatStore.setState({ use24HourTime: false }); + }); + + it('setUse24HourTime persists and updates state', () => { + useTimeFormatStore.getState().setUse24HourTime(true); + expect(useTimeFormatStore.getState().use24HourTime).toBe(true); + expect(mergeAppSetting).toHaveBeenCalledWith( + 'use24HourTime', + true, + 'timeFormatStore setUse24HourTime', + ); + }); +}); diff --git a/src/renderer/stores/timeFormatStore.ts b/src/renderer/stores/timeFormatStore.ts new file mode 100644 index 000000000..c658b74c6 --- /dev/null +++ b/src/renderer/stores/timeFormatStore.ts @@ -0,0 +1,30 @@ +import { create } from 'zustand'; + +import { getAppSettingsRaw, mergeAppSetting } from '../lib/appSettingsStorage'; +import { parseStoredJson } from '../lib/parseStoredJson'; + +function loadUse24HourTime(): boolean { + try { + const o = parseStoredJson<{ use24HourTime?: boolean }>( + getAppSettingsRaw(), + 'timeFormatStore loadUse24HourTime', + ); + return o?.use24HourTime === true; + } catch { + // catch-no-log-ok: localStorage unavailable in node / restricted environments + return false; + } +} + +interface TimeFormatState { + use24HourTime: boolean; + setUse24HourTime(value: boolean): void; +} + +export const useTimeFormatStore = create((set) => ({ + use24HourTime: loadUse24HourTime(), + setUse24HourTime(value) { + mergeAppSetting('use24HourTime', value, 'timeFormatStore setUse24HourTime'); + set({ use24HourTime: value }); + }, +})); From b47c5859003c21de3784fffbc5b1fb6363539d6f Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 29 Jul 2026 15:10:36 -0600 Subject: [PATCH 04/21] =?UTF-8?q?fix(reticulum):=20stop=20link-timeout=20b?= =?UTF-8?q?ridge=20from=20locking=20PN=20=E2=9C=97=20after=20Direct?= =?UTF-8?q?=E2=86=92PN=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip premature Failed marks when a remote preferred PN is available, and revive failed→sending when the sidecar emits sending+propagated so the badge is not stuck. --- ...plyReticulumOutboundDeliveryStatus.test.ts | 43 +++++++++++ .../applyReticulumOutboundDeliveryStatus.ts | 24 ++++++ .../reticulumOutboundFailureBridge.test.ts | 76 ++++++++++++++++++- .../reticulumOutboundFailureBridge.ts | 18 +++++ ...ticulumRuntime.reconnect-hardening.test.ts | 8 ++ src/renderer/runtime/useReticulumRuntime.ts | 12 ++- 6 files changed, 179 insertions(+), 2 deletions(-) diff --git a/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.test.ts b/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.test.ts index 1939e3453..ec9dad660 100644 --- a/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.test.ts +++ b/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.test.ts @@ -326,6 +326,49 @@ describe('applyReticulumOutboundDeliveryStatus', () => { ).toBe('propagated'); }); + it('revives Failed to sending when Direct→PN fallback WS arrives after link-timeout bridge', () => { + const toNodeId = reticulumHashToNodeId(DEST); + const selfNodeId = reticulumHashToNodeId(SELF); + registerReticulumDestinationHash(toNodeId, DEST); + registerReticulumDestinationHash(selfNodeId, SELF); + useMessageStore.setState({ + messages: { + [identityId]: { + [messageHash]: { + id: messageHash, + from: selfNodeId, + to: toNodeId, + senderName: 'Me', + payload: 'race', + channelIndex: 0, + timestamp: Date.now(), + status: 'failed', + error: 'Failed to send', + reticulumMessageHash: messageHash, + reticulumSenderHash: SELF, + reticulumDeliveryMethod: 'direct', + }, + }, + }, + }); + + applyReticulumOutboundDeliveryStatus(identityId, messageHash, 'sending', { + deliveryMethod: 'propagated', + }); + + const row = useMessageStore.getState().messages[identityId]?.[messageHash]; + expect(row?.status).toBe('sending'); + expect(row?.reticulumDeliveryMethod).toBe('propagated'); + expect(row?.error).toBeUndefined(); + expect(window.electronAPI.db.saveReticulumMessage).toHaveBeenCalledWith( + expect.objectContaining({ + message_hash: messageHash, + delivery_status: 'sending', + delivery_method: 'propagated', + }), + ); + }); + it('drops invalid message_hash and unknown wire status', () => { useMessageStore.setState({ messages: { diff --git a/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.ts b/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.ts index bb50c78aa..c566b7445 100644 --- a/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.ts +++ b/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.ts @@ -144,6 +144,30 @@ export function persistReticulumOutboundMessageStatus( ): boolean { const before = useMessageStore.getState().messages[identityId]?.[messageId]; if (!before) return false; + // Link-timeout failure bridge can mark Failed before WS Direct→PN fallback arrives. + // Authoritative sending+propagated must revive so the badge is not stuck as PN ✗. + if (before.status === 'failed' && status === 'sending' && deliveryMethod === 'propagated') { + const revived: MessageRecord = { + ...before, + status: 'sending', + error: undefined, + reticulumDeliveryMethod: 'propagated', + ...(sentVia != null ? { receivedVia: sentVia } : {}), + }; + upsertMessage(identityId, revived); + const senderHash = resolveOutboundSenderHash(revived); + if (senderHash) { + persistReticulumOutboundRecord( + identityId, + revived, + senderHash, + revived.senderName ?? '', + resolveOutboundPeerHash(revived), + 'sending', + ); + } + return true; + } // Do not regress a terminal Completes/Fails back to sending — still allow via/method patches. if (isTerminalStatus(before.status ?? 'sending') && status === 'sending') { const viaChanged = sentVia != null && sentVia !== before.receivedVia; diff --git a/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.test.ts b/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.test.ts index 7b79b6471..95c3a47d7 100644 --- a/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.test.ts +++ b/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.test.ts @@ -1,18 +1,32 @@ // @vitest-environment jsdom import { beforeEach, describe, expect, it } from 'vitest'; +import { applyReticulumOutboundDeliveryStatus } from '@/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus'; import { registerReticulumDestinationHash, reticulumHashToNodeId, } from '@/renderer/lib/reticulum/destHash'; -import { failReticulumSendingOutboundToDestHash } from '@/renderer/lib/reticulum/reticulumOutboundFailureBridge'; +import { + failReticulumSendingOutboundToDestHash, + shouldApplyLinkDeliveryTimeoutFailureBridge, +} from '@/renderer/lib/reticulum/reticulumOutboundFailureBridge'; import { useMessageStore } from '@/renderer/stores/messageStore'; +import type { PropagationNodeRow } from '@/renderer/stores/reticulumPropagationStore'; import { createElectronAPIMock } from '@/renderer/vitest.electronApiMock'; const DEST = '5526a65d0b4d23448206fd3485b76f5b'; const SELF = '8fd7a9361aca12360c7985bc934bdd20'; const identityId = 'reticulum-test'; +const remoteNode: PropagationNodeRow = { + id: 'pn-remote', + name: 'Remote PN', + enabled: true, + status: 'active', + preferred: true, + destination_hash: '473a7d8a6fce3314e61915cc20060915', +}; + describe('failReticulumSendingOutboundToDestHash', () => { beforeEach(() => { useMessageStore.setState({ messages: {} }); @@ -104,4 +118,64 @@ describe('failReticulumSendingOutboundToDestHash', () => { expect(count).toBe(0); expect(useMessageStore.getState().messages[identityId]?.['msg-hash']?.status).toBe('sending'); }); + + it('race: bridge Failed then WS sending+propagated revives via apply', () => { + const toNodeId = reticulumHashToNodeId(DEST); + registerReticulumDestinationHash(toNodeId, DEST); + const messageHash = '0079618cd4762a8edb2adbeed0e2d1d4f0e034b8991c3f28976d4b8629bcee76'; + useMessageStore.setState({ + messages: { + [identityId]: { + [messageHash]: { + id: messageHash, + from: 1, + senderName: 'self', + payload: 'hello', + channelIndex: 0, + timestamp: Date.now(), + status: 'sending', + to: toNodeId, + reticulumMessageHash: messageHash, + reticulumSenderHash: SELF, + reticulumDeliveryMethod: 'direct', + }, + }, + }, + }); + + expect(failReticulumSendingOutboundToDestHash(identityId, DEST, 'link timeout')).toBe(1); + expect(useMessageStore.getState().messages[identityId]?.[messageHash]?.status).toBe('failed'); + + applyReticulumOutboundDeliveryStatus(identityId, messageHash, 'sending', { + deliveryMethod: 'propagated', + }); + const row = useMessageStore.getState().messages[identityId]?.[messageHash]; + expect(row?.status).toBe('sending'); + expect(row?.reticulumDeliveryMethod).toBe('propagated'); + }); +}); + +describe('shouldApplyLinkDeliveryTimeoutFailureBridge', () => { + it('returns false when preferred remote PN is set (sidecar owns Direct→PN fallback)', () => { + expect(shouldApplyLinkDeliveryTimeoutFailureBridge([remoteNode], 'pn-remote', 'off')).toBe( + false, + ); + }); + + it('returns true when only local-prop is available', () => { + const localOnly: PropagationNodeRow = { + id: 'local-prop', + name: 'Local', + enabled: true, + status: 'active', + preferred: true, + }; + expect(shouldApplyLinkDeliveryTimeoutFailureBridge([localOnly], 'local-prop', 'auto')).toBe( + true, + ); + }); + + it('returns true when no remote PN target exists', () => { + expect(shouldApplyLinkDeliveryTimeoutFailureBridge([], null, 'off')).toBe(true); + }); }); diff --git a/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.ts b/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.ts index 754814661..d52ee855b 100644 --- a/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.ts +++ b/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.ts @@ -1,13 +1,31 @@ import { persistReticulumOutboundMessageStatus } from '@/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus'; import { resolveReticulumDestinationHash } from '@/renderer/lib/reticulum/destHash'; +import { hasEffectiveReticulumPropagationTarget } from '@/renderer/lib/reticulum/reticulumPropagationEffective'; +import { + readReticulumPropagationMode, + type ReticulumPropagationMode, +} from '@/renderer/lib/reticulum/reticulumPropagationMode'; import type { IdentityId } from '@/renderer/lib/types'; import { useMessageStore } from '@/renderer/stores/messageStore'; import { reticulumHashForNodeId } from '@/renderer/stores/reticulumPeerStore'; +import type { PropagationNodeRow } from '@/renderer/stores/reticulumPropagationStore'; function normalizeDestHash(hash: string): string { return hash.replace(/[^0-9a-f]/gi, '').toLowerCase(); } +/** + * When a remote preferred PN is available, sidecar owns Direct timeout via + * one-shot PN fallback + `lxmf_outbound_status`. Skip the premature Failed bridge. + */ +export function shouldApplyLinkDeliveryTimeoutFailureBridge( + nodes: PropagationNodeRow[], + preferredId: string | null, + mode: ReticulumPropagationMode = readReticulumPropagationMode(), +): boolean { + return !hasEffectiveReticulumPropagationTarget(nodes, preferredId, mode); +} + function destHashMatchesPeer(storedHash: string, targetNorm: string): boolean { const storedNorm = normalizeDestHash(storedHash); if (!storedNorm || !targetNorm) return false; diff --git a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts index aca197bdc..c5645c7e7 100644 --- a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts @@ -225,6 +225,14 @@ describe('useReticulumRuntime outbound delivery persistence', () => { expect(SOURCE).toMatch(/flushPendingReticulumOutboundDeliveryStatus\(identityId, hash\)/); }); + it('skips link-timeout failure bridge when remote PN fallback is available', () => { + expect(SOURCE).toContain('shouldApplyLinkDeliveryTimeoutFailureBridge'); + expect(SOURCE).toMatch( + /shouldApplyLinkDeliveryTimeoutFailureBridge\(\s*propState\.nodes,\s*propState\.preferredId,\s*\)/, + ); + expect(SOURCE).toMatch(/if \(!applyBridge\) continue/); + }); + it('wires propagation store + sidecar health into Reticulum diagnostics', () => { expect(SOURCE).toMatch(/sidecarUnhealthySince:\s*sidecarStatus\.unhealthySince/); expect(SOURCE).toMatch(/useReticulumPropagationStore\.subscribe/); diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index e6799ae18..2fc859964 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -56,7 +56,10 @@ import { isReticulumManualStackStopSuppress, setReticulumManualStackStopSuppress, } from '@/renderer/lib/reticulum/reticulumManualStackStopSuppress'; -import { failReticulumSendingOutboundToDestHash } from '@/renderer/lib/reticulum/reticulumOutboundFailureBridge'; +import { + failReticulumSendingOutboundToDestHash, + shouldApplyLinkDeliveryTimeoutFailureBridge, +} from '@/renderer/lib/reticulum/reticulumOutboundFailureBridge'; import { applyPropagationSyncEvent, RETICULUM_PROPAGATION_SYNC_STALL_MS, @@ -1104,10 +1107,17 @@ export function useReticulumRuntime(): ProtocolRuntime { void syncDiagnosticsFromSidecar(); const timeouts = status.interfaceIssueAlert?.linkDeliveryTimeouts; if (identityId && timeouts?.length) { + const propState = useReticulumPropagationStore.getState(); + const applyBridge = shouldApplyLinkDeliveryTimeoutFailureBridge( + propState.nodes, + propState.preferredId, + ); for (const { destinationHash } of timeouts) { const norm = destinationHash.replace(/[^0-9a-f]/gi, '').toLowerCase(); if (!norm || processedLinkTimeoutDestsRef.current.has(norm)) continue; processedLinkTimeoutDestsRef.current.add(norm); + // Remote preferred PN: sidecar Direct→PN fallback owns the outcome via WS. + if (!applyBridge) continue; failReticulumSendingOutboundToDestHash( identityId, norm, From eb3259f26773f308838c3600c9b7d8fda0c881fc Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 29 Jul 2026 15:15:49 -0600 Subject: [PATCH 05/21] fix(rrc): soften involuntary part banner and log hub PART events Reserve kick/ban wording for moderation NOTICE/ERROR language; show a neutral hub-parted banner (and skip it while reconnecting) so idle/link drops are not framed as bans. --- .../lib/rrcInvoluntaryPartBanner.test.ts | 34 ++++++++++++++ src/renderer/lib/rrcInvoluntaryPartBanner.ts | 22 ++++++++++ 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 +- .../runtime/useReticulumRuntime.rrc.test.ts | 28 ++++++++++++ src/renderer/runtime/useReticulumRuntime.ts | 44 +++++++++++++++++-- 20 files changed, 155 insertions(+), 19 deletions(-) create mode 100644 src/renderer/lib/rrcInvoluntaryPartBanner.test.ts create mode 100644 src/renderer/lib/rrcInvoluntaryPartBanner.ts diff --git a/src/renderer/lib/rrcInvoluntaryPartBanner.test.ts b/src/renderer/lib/rrcInvoluntaryPartBanner.test.ts new file mode 100644 index 000000000..1ad360641 --- /dev/null +++ b/src/renderer/lib/rrcInvoluntaryPartBanner.test.ts @@ -0,0 +1,34 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; + +import { resolveRrcInvoluntaryPartBannerKey } from './rrcInvoluntaryPartBanner'; + +describe('resolveRrcInvoluntaryPartBannerKey', () => { + it('returns null for voluntary part', () => { + expect(resolveRrcInvoluntaryPartBannerKey({ voluntary: true })).toBeNull(); + expect( + resolveRrcInvoluntaryPartBannerKey({ voluntary: true, sessionStatus: 'active' }), + ).toBeNull(); + }); + + it('returns null while reconnecting (softens link-drop parts)', () => { + expect( + resolveRrcInvoluntaryPartBannerKey({ + voluntary: false, + sessionStatus: 'reconnecting', + }), + ).toBeNull(); + }); + + it('uses neutral hubParted for involuntary part when not reconnecting', () => { + expect(resolveRrcInvoluntaryPartBannerKey({ voluntary: false })).toBe( + 'rrc.moderation.hubParted', + ); + expect(resolveRrcInvoluntaryPartBannerKey({ voluntary: false, sessionStatus: 'active' })).toBe( + 'rrc.moderation.hubParted', + ); + expect( + resolveRrcInvoluntaryPartBannerKey({ voluntary: false, sessionStatus: 'disconnected' }), + ).toBe('rrc.moderation.hubParted'); + }); +}); diff --git a/src/renderer/lib/rrcInvoluntaryPartBanner.ts b/src/renderer/lib/rrcInvoluntaryPartBanner.ts new file mode 100644 index 000000000..985f55ece --- /dev/null +++ b/src/renderer/lib/rrcInvoluntaryPartBanner.ts @@ -0,0 +1,22 @@ +/** + * Resolve the sticky RRC banner i18n key after an involuntary hub PART. + * Kick/ban wording is reserved for `isRrcModerationLanguage` notice/error paths. + */ + +export interface ResolveRrcInvoluntaryPartBannerOpts { + voluntary: boolean; + /** Skip while sidecar auto-reconnect is in flight (link drop → rejoin). */ + sessionStatus?: string | null; +} + +/** + * Returns an i18n key for the moderation banner, or `null` when no banner should show + * (voluntary `/part`, or reconnect already under way). + */ +export function resolveRrcInvoluntaryPartBannerKey( + opts: ResolveRrcInvoluntaryPartBannerOpts, +): string | null { + if (opts.voluntary) return null; + if (opts.sessionStatus === 'reconnecting') return null; + return 'rrc.moderation.hubParted'; +} diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 60d8d3a1f..11ebf70c7 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -4575,7 +4575,8 @@ "directNoticeUnsupported": "Tento rozbočovač nepodporuje přímé OZNÁMENÍ (/msg).", "capDirectNotice": "direct msg", "moderation": { - "removedFromRoom": "Odstraněno z místnosti nábojem (kop, zákaz nebo vzdálená část)." + "removedFromRoom": "Odstraněno z místnosti nábojem (kop, zákaz nebo vzdálená část).", + "hubParted": "Opustila místnost (hub vás rozdělil)." }, "errors": { "linkProofTimeout": "Vypršel časový limit čekání na odkaz na centrum (důkaz odkazu). Zkuste to znovu nebo zkontrolujte cestu/přechody k rozbočovači.", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index b908f1c8c..9f882e9ca 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -4573,7 +4573,8 @@ "directNoticeUnsupported": "Dieser Hub unterstützt keine direkte BENACHRICHTIGUNG (/msg).", "capDirectNotice": "direkte Nachricht", "moderation": { - "removedFromRoom": "Vom Hub aus dem Raum entfernt (Kick, Ban oder Remote Part)." + "removedFromRoom": "Vom Hub aus dem Raum entfernt (Kick, Ban oder Remote Part).", + "hubParted": "Verließ den Raum (Hub hat dich getrennt)." }, "errors": { "linkProofTimeout": "Zeitüberschreitung beim Warten auf den Hub-Link (Link-Proof). Versuchen Sie es erneut oder überprüfen Sie Pfad/Sprünge zum Hub.", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 72510665b..044325b81 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -2929,6 +2929,7 @@ "welcomeTimeout": "Connected but the hub did not send WELCOME in time. Try again." }, "moderation": { + "hubParted": "Left the room (hub parted you).", "removedFromRoom": "Removed from room by the hub (kick, ban, or remote part)." }, "slash": { diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 69922cd83..2116a6e43 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -4573,7 +4573,8 @@ "directNoticeUnsupported": "Este concentrador no admite NOTIFICACIÓN directa (/msg).", "capDirectNotice": "msg directo", "moderation": { - "removedFromRoom": "Eliminado de la sala por el centro (patada, prohibición o parte remota)." + "removedFromRoom": "Eliminado de la sala por el centro (patada, prohibición o parte remota).", + "hubParted": "Saliste de la sala (el hub te separó)." }, "errors": { "linkProofTimeout": "Se agotó el tiempo de espera para el enlace del hub (prueba del enlace). Inténtalo de nuevo o comprueba la ruta/saltos al centro.", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 0c8c626d1..e0bd9bd28 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -4573,7 +4573,8 @@ "directNoticeUnsupported": "Ce hub ne prend pas en charge la NOTIFICATION directe (/msg).", "capDirectNotice": "msg direct", "moderation": { - "removedFromRoom": "Retiré de la salle par le moyeu (coup de pied, bannissement ou salle distante)." + "removedFromRoom": "Retiré de la salle par le moyeu (coup de pied, bannissement ou salle distante).", + "hubParted": "A quitté la salle (le hub vous a séparé)." }, "errors": { "linkProofTimeout": "Délai expiré en attendant le lien du hub (preuve de lien). Réessayez ou vérifiez le chemin/les sauts vers le hub.", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 3022720f6..659ed3421 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -4573,7 +4573,8 @@ "directNoticeUnsupported": "Hub ini tidak mendukung PEMBERITAHUAN langsung (/msg).", "capDirectNotice": "direct msg", "moderation": { - "removedFromRoom": "Dilepaskan dari ruangan oleh hub (kick, ban, atau remote part)." + "removedFromRoom": "Dilepaskan dari ruangan oleh hub (kick, ban, atau remote part).", + "hubParted": "Meninggalkan ruangan (hub memisahkan Anda)." }, "errors": { "linkProofTimeout": "Waktu habis menunggu tautan hub (bukti tautan). Coba lagi, atau periksa jalur/hop ke hub.", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index f13694753..51c2022b2 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -4573,7 +4573,8 @@ "directNoticeUnsupported": "Questo hub non supporta l'AVVISO diretto (/msg).", "capDirectNotice": "msg diretto", "moderation": { - "removedFromRoom": "Rimosso dalla stanza dall'hub (kick, ban o parte remota)." + "removedFromRoom": "Rimosso dalla stanza dall'hub (kick, ban o parte remota).", + "hubParted": "Ha lasciato la stanza (il mozzo ti ha separato)." }, "errors": { "linkProofTimeout": "Tempo scaduto in attesa del collegamento hub (prova del collegamento). Riprova o controlla il percorso/salti verso l'hub.", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 6fba8c659..632450b86 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -4573,7 +4573,8 @@ "directNoticeUnsupported": "このハブは直接通知(/msg )をサポートしていません。", "capDirectNotice": "direct msg", "moderation": { - "removedFromRoom": "ハブによってルームから取り除かれました(キック、禁止、またはリモートパーツ)。" + "removedFromRoom": "ハブによってルームから取り除かれました(キック、禁止、またはリモートパーツ)。", + "hubParted": "ルームを出ました(ハブによって退出させられました)。" }, "errors": { "linkProofTimeout": "ハブリンク(リンクプルーフ)の待機中にタイムアウトしました。もう一度試すか、ハブへのパス/ホップを確認してください。", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 5bb5c353d..e8e49dca2 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -4573,7 +4573,8 @@ "directNoticeUnsupported": "이 허브는 직접 고지 (/msg) 를 지원하지 않습니다.", "capDirectNotice": "direct msg", "moderation": { - "removedFromRoom": "허브에 의해 공간에서 제거됨 (킥, 금지 또는 원격 부품)." + "removedFromRoom": "허브에 의해 공간에서 제거됨 (킥, 금지 또는 원격 부품).", + "hubParted": "방을 나갔습니다 (허브가 당신을 헤어뜨렸습니다)." }, "errors": { "linkProofTimeout": "허브 링크 (링크 증명) 대기 시간이 초과되었습니다. 다시 시도하거나 허브 경로/홉을 확인하세요.", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 3874ef544..248098a13 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -4573,7 +4573,8 @@ "directNoticeUnsupported": "Deze hub ondersteunt geen directe KENNISGEVING (/msg).", "capDirectNotice": "direct msg", "moderation": { - "removedFromRoom": "Verwijderd uit de kamer door de hub (kick, ban of extern deel)." + "removedFromRoom": "Verwijderd uit de kamer door de hub (kick, ban of extern deel).", + "hubParted": "Verliet de kamer (hub scheidde je)." }, "errors": { "linkProofTimeout": "Time-out bij het wachten op de hublink (linkbewijs). Probeer het opnieuw of controleer het pad/de hops naar de hub.", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 54c2dcebe..f9a815c2f 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -4575,7 +4575,8 @@ "directNoticeUnsupported": "Ten koncentrator nie obsługuje POWIADOMIEŃ bezpośrednich (/msg).", "capDirectNotice": "direct msg", "moderation": { - "removedFromRoom": "Usunięty z pokoju przez piastę (kopnięcie, zakaz lub część zdalna)." + "removedFromRoom": "Usunięty z pokoju przez piastę (kopnięcie, zakaz lub część zdalna).", + "hubParted": "Opuścił pokój (hub was rozdzielił)." }, "errors": { "linkProofTimeout": "Przekroczono limit czasu oczekiwania na połączenie z hubem (link proof). Spróbuj ponownie lub sprawdź ścieżkę/przeskoki do centrum.", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 6ca3c2740..4115c88bf 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -4573,7 +4573,8 @@ "directNoticeUnsupported": "Este hub não suporta AVISO direto (/msg).", "capDirectNotice": "msg direto", "moderation": { - "removedFromRoom": "Removido da sala pelo hub (kick, ban ou peça remota)." + "removedFromRoom": "Removido da sala pelo hub (kick, ban ou peça remota).", + "hubParted": "Saiu da sala (o hub se separou de você)." }, "errors": { "linkProofTimeout": "Tempo limite esgotado aguardando o link do hub (prova de link). Tente novamente ou verifique o caminho/saltos para o hub.", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 8cd70f220..bf6afed62 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -4575,7 +4575,8 @@ "directNoticeUnsupported": "Этот концентратор не поддерживает прямое УВЕДОМЛЕНИЕ (/msg).", "capDirectNotice": "direct msg", "moderation": { - "removedFromRoom": "Удаляется из комната ступицей (удар, блокировка или удаленная часть)." + "removedFromRoom": "Удаляется из комната ступицей (удар, блокировка или удаленная часть).", + "hubParted": "Вышел из комнаты (хаб разлучил тебя)." }, "errors": { "linkProofTimeout": "Время ожидания ссылки на концентратор истекло (подтверждение ссылки). Повторите попытку или проверьте путь/переходы к концентратору.", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index eb43feb06..4c08be24a 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -4573,7 +4573,8 @@ "directNoticeUnsupported": "Bu merkez doğrudan BİLDİRİMİ (/msg) desteklemiyor.", "capDirectNotice": "doğrudan msg", "moderation": { - "removedFromRoom": "Göbek tarafından odadan çıkarılır (tekme, yasak veya uzak kısım)." + "removedFromRoom": "Göbek tarafından odadan çıkarılır (tekme, yasak veya uzak kısım).", + "hubParted": "Odadan ayrıldı (merkez sizi ayırdı)." }, "errors": { "linkProofTimeout": "Göbek bağlantısını beklerken zaman aşımına uğradı (bağlantı kanıtı). Tekrar deneyin veya merkeze giden yolu/durakları kontrol edin.", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index d73199b24..814ce4116 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -4575,7 +4575,8 @@ "directNoticeUnsupported": "Цей концентратор не підтримує пряме СПОВІЩЕННЯ (/msg).", "capDirectNotice": "пряме повідомлення", "moderation": { - "removedFromRoom": "Вилучено з кімната за допомогою концентратора (удар, заборона або віддалена частина)." + "removedFromRoom": "Вилучено з кімната за допомогою концентратора (удар, заборона або віддалена частина).", + "hubParted": "Вийшов з кімнати (хаб розлучив вас)." }, "errors": { "linkProofTimeout": "Час очікування посилання на концентратор закінчився (підтвердження посилання). Спробуйте ще раз або перевірте шлях/шляхи до концентратора.", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index ade30a41c..debd5393e 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -4573,7 +4573,8 @@ "directNoticeUnsupported": "此集线器不支持直接通知(/msg)。", "capDirectNotice": "direct msg", "moderation": { - "removedFromRoom": "由轮毂从房间移除(踢出、禁止或远程部分)。" + "removedFromRoom": "由轮毂从房间移除(踢出、禁止或远程部分)。", + "hubParted": "离开房间( HUB将您分开)。" }, "errors": { "linkProofTimeout": "等待集线器链接(链接证明)超时。重试,或检查到集线器的路径/跳数。", diff --git a/src/renderer/runtime/useReticulumRuntime.rrc.test.ts b/src/renderer/runtime/useReticulumRuntime.rrc.test.ts index ebbab5bed..83b607564 100644 --- a/src/renderer/runtime/useReticulumRuntime.rrc.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.rrc.test.ts @@ -40,4 +40,32 @@ describe('useReticulumRuntime RRC event routing (regression)', () => { expect(SOURCE).toMatch(/hub_dest_hash\?: string \| null/); expect(SOURCE).toMatch(/addMessage\([\s\S]*?\{ hubDestHash \}/); }); + + it('uses neutral hubParted banner for involuntary parts (not kick/ban wording)', () => { + expect(SOURCE).toMatch(/resolveRrcInvoluntaryPartBannerKey/); + expect(SOURCE).toMatch(/sessionStatus: view\.status/); + expect(SOURCE).toMatch( + /if \(bannerKey\) session\.setModerationBanner\(bannerKey, hubDestHash\)/, + ); + // Parted path must not hard-code the kick/ban key (moderation NOTICE/ERROR still may). + expect(SOURCE).toMatch( + /evt\.type === 'rrc\.room\.parted'[\s\S]*?resolveRrcInvoluntaryPartBannerKey\([\s\S]*?if \(bannerKey\) session\.setModerationBanner\(bannerKey/, + ); + }); + + it('reserves removedFromRoom banner for moderation NOTICE/ERROR language', () => { + expect(SOURCE).toMatch( + /isRrcModerationLanguage\(p\.body\)[\s\S]*?setModerationBanner\('rrc\.moderation\.removedFromRoom'/, + ); + expect(SOURCE).toMatch( + /isRrcModerationLanguage\(p\.message\)[\s\S]*?setModerationBanner\('rrc\.moderation\.removedFromRoom'/, + ); + }); + + it('debug-logs rrc.disconnected and rrc.room.parted with hub/room/voluntary', () => { + expect(SOURCE).toMatch(/console\.debug\(\s*'\[useReticulumRuntime\] rrc\.disconnected hub='/); + expect(SOURCE).toMatch(/console\.debug\(\s*'\[useReticulumRuntime\] rrc\.room\.parted hub='/); + expect(SOURCE).toMatch(/voluntary='/); + expect(SOURCE).toMatch(/will_reconnect='/); + }); }); diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 2fc859964..736138615 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { sanitizeLogMessage } from '@/main/sanitize-log-message'; import { pushAppToast } from '@/renderer/components/Toast'; import { applyRncpReceiveDestShareFromLxmf, @@ -107,6 +108,7 @@ import { import { getIdentityIdForProtocol } from '../lib/identityByProtocol'; import { getOfflineIdentityIdForProtocol } from '../lib/offlineProtocolIdentities'; +import { resolveRrcInvoluntaryPartBannerKey } from '../lib/rrcInvoluntaryPartBanner'; import { isRrcJoinInfoNotice, isRrcModerationLanguage, @@ -172,10 +174,16 @@ function resolveRrcHubView(hubHash: string | undefined): { hub: string | null; activeRoom: string | null; partIntentRooms: Set; + status: string | null; } { const s = useRrcSessionStore.getState(); if (!hubHash) { - return { hub: s.focusedHubHash, activeRoom: s.activeRoom, partIntentRooms: s.partIntentRooms }; + return { + hub: s.focusedHubHash, + activeRoom: s.activeRoom, + partIntentRooms: s.partIntentRooms, + status: s.status, + }; } const hub = hubHash.toLowerCase(); const session = s.sessionsByHub.get(hub); @@ -183,6 +191,7 @@ function resolveRrcHubView(hubHash: string | undefined): { hub, activeRoom: session?.activeRoom ?? null, partIntentRooms: session?.partIntentRooms ?? new Set(), + status: session?.status ?? null, }; } @@ -773,6 +782,16 @@ export function useReticulumRuntime(): ProtocolRuntime { const hubSession = session.sessionsByHub.get(hubDestHash.toLowerCase()); const disconnectIntentForHub = hubSession?.disconnectIntent ?? false; const willReconnect = p.will_reconnect === true; + console.debug( + '[useReticulumRuntime] rrc.disconnected hub=' + + sanitizeLogMessage(hubDestHash) + + ' reason=' + + sanitizeLogMessage(p.reason ?? '') + + ' will_reconnect=' + + String(p.will_reconnect) + + ' disconnectIntent=' + + String(disconnectIntentForHub), + ); if ( p.reason === 'local_disconnect' || disconnectIntentForHub || @@ -807,8 +826,24 @@ export function useReticulumRuntime(): ProtocolRuntime { const view = resolveRrcHubView(hubDestHash); const session = useRrcSessionStore.getState(); const voluntary = [...view.partIntentRooms].some((k) => rrcRoomsMatch(k, p.room!)); + const bannerKey = resolveRrcInvoluntaryPartBannerKey({ + voluntary, + sessionStatus: view.status, + }); + console.debug( + '[useReticulumRuntime] rrc.room.parted hub=' + + sanitizeLogMessage(hubDestHash ?? '') + + ' room=' + + sanitizeLogMessage(p.room) + + ' voluntary=' + + String(voluntary) + + ' status=' + + sanitizeLogMessage(view.status ?? '') + + ' banner=' + + sanitizeLogMessage(bannerKey ?? 'none'), + ); if (!voluntary) { - session.setModerationBanner('rrc.moderation.removedFromRoom', hubDestHash); + if (bannerKey) session.setModerationBanner(bannerKey, hubDestHash); session.addMessage( { id: `part-${Date.now()}`, @@ -864,7 +899,8 @@ export function useReticulumRuntime(): ProtocolRuntime { session.roomJoined(topic.room, undefined, hubDestHash); } if (isRrcModerationLanguage(p.body)) { - session.setModerationBanner(p.body, hubDestHash); + // Reserve kick/ban banner copy for moderation notices; transcript keeps hub text. + session.setModerationBanner('rrc.moderation.removedFromRoom', hubDestHash); } } @@ -918,7 +954,7 @@ export function useReticulumRuntime(): ProtocolRuntime { // Keep raw message; panel humanizes for display. Do not freeze UI on timeouts. session.setError(p.message, hubDestHash); if (isRrcModerationLanguage(p.message)) { - session.setModerationBanner(p.message, hubDestHash); + session.setModerationBanner('rrc.moderation.removedFromRoom', hubDestHash); } if (view.hub) { session.addMessage( From 1cc8ba41def1cfefd834d172dc7d1e5211981f36 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 29 Jul 2026 15:43:50 -0600 Subject: [PATCH 06/21] feat(reticulum): host local LXMF PN with peering policy controls Build real local propagation-node serving (announce, /offer|/get, autopeer), persist hosting policy, gate Sync/Add on cost and /offer capability, and expose Advanced PN hosting on Network. --- docs/reticulum-sidecar-ipc.md | 11 +- docs/reticulum.md | 4 +- docs/troubleshooting.md | 12 +- reticulum-sidecar/src/api/mod.rs | 4 + reticulum-sidecar/src/api/propagation.rs | 14 +- reticulum-sidecar/src/stack/live.rs | 176 +++++++++ reticulum-sidecar/src/stack/mod.rs | 33 ++ reticulum-sidecar/src/stack/persistence.rs | 18 +- .../src/stack/pn_hosting_apply.rs | 48 +++ .../src/stack/pn_hosting_policy.rs | 205 +++++++++++ .../src/stack/propagation_announce.rs | 186 ++++++++++ .../src/stack/propagation_bridge.rs | 44 ++- .../src/stack/propagation_serve.rs | 135 +++++++ .../components/ReticulumNetworkPanel.tsx | 3 + .../ReticulumPnHostingDangerZone.test.tsx | 84 +++++ .../ReticulumPnHostingDangerZone.tsx | 345 ++++++++++++++++++ .../ReticulumPropagationSection.test.tsx | 39 +- .../ReticulumPropagationSection.tsx | 43 ++- .../reticulumPropagationSync.test.ts | 9 + .../lib/reticulum/reticulumPropagationSync.ts | 8 + src/renderer/locales/cs/translation.json | 42 ++- src/renderer/locales/de/translation.json | 42 ++- src/renderer/locales/en/translation.json | 42 ++- src/renderer/locales/es/translation.json | 42 ++- src/renderer/locales/fr/translation.json | 42 ++- src/renderer/locales/id/translation.json | 42 ++- src/renderer/locales/it/translation.json | 42 ++- src/renderer/locales/ja/translation.json | 42 ++- src/renderer/locales/ko/translation.json | 42 ++- src/renderer/locales/nl/translation.json | 42 ++- src/renderer/locales/pl/translation.json | 42 ++- src/renderer/locales/pt-BR/translation.json | 42 ++- src/renderer/locales/ru/translation.json | 42 ++- src/renderer/locales/tr/translation.json | 42 ++- src/renderer/locales/uk/translation.json | 42 ++- src/renderer/locales/zh/translation.json | 42 ++- .../stores/reticulumPropagationStore.ts | 38 +- src/shared/pnHostingPolicy.test.ts | 25 ++ src/shared/pnHostingPolicy.ts | 101 +++++ 39 files changed, 2158 insertions(+), 99 deletions(-) create mode 100644 reticulum-sidecar/src/stack/pn_hosting_apply.rs create mode 100644 reticulum-sidecar/src/stack/pn_hosting_policy.rs create mode 100644 reticulum-sidecar/src/stack/propagation_announce.rs create mode 100644 reticulum-sidecar/src/stack/propagation_serve.rs create mode 100644 src/renderer/components/ReticulumPnHostingDangerZone.test.tsx create mode 100644 src/renderer/components/ReticulumPnHostingDangerZone.tsx create mode 100644 src/shared/pnHostingPolicy.test.ts create mode 100644 src/shared/pnHostingPolicy.ts diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index af393e15f..27465ab5d 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -91,13 +91,14 @@ The Connection tab UI edits a subset: **name** and **mode** for all types; **hos **WS `rmap.discovery`:** sidecar polls DiscoveryStore every **10s**; emits full `{ discovered: [...] }` snapshot when JSON fingerprint changes. Stub builds return `{ discovered: [] }`. | GET | `/api/v1/packets` | `?limit=500` (1–2500) | `{ packets: [] }` — recent wire tap ring buffer | | DELETE | `/api/v1/packets` | | `{ ok }` — clear wire tap buffer | -| GET | `/api/v1/propagation` | | `{ propagation, preferred_id, auto_sync_interval_sec }` — `local-prop` rows include `message_count`, `storage_bytes` when live | +| GET | `/api/v1/propagation` | | `{ propagation, preferred_id, auto_sync_interval_sec, pn_hosting_policy }` — `local-prop` rows include `message_count`, `storage_bytes` when live | | GET | `/api/v1/propagation/discovered` | | `{ discovered: DiscoveredPropagationRow[] }` — heard `lxmf.propagation` announces (not auto-configured) | -| POST | `/api/v1/propagation/add` | `{ destination_hash, name? }` | `{ ok, node }` — add a remote propagation node by hash | +| POST | `/api/v1/propagation/add` | `{ destination_hash, name?, skip_probe? }` | `{ ok, node }` or `{ ok: false, error }` — probes `/offer` unless `skip_probe`; may return `PROPAGATION_OFFER_UNSUPPORTED`, `PROPAGATION_PEER_COST_EXCEEDS_MAX`, identity/path errors | +| POST | `/api/v1/propagation/hosting-policy` | `PnHostingPolicy` | `{ ok }` — persist + apply local PN hosting / peering policy | | PUT | `/api/v1/propagation/{id}` | `{ name }` | `{ ok }` — rename a remote node (`local-prop` rejected) | | DELETE | `/api/v1/propagation/{id}` | | `{ ok }` — remove a remote node (`local-prop` rejected; clears preferred if that id) | -| POST | `/api/v1/propagation/{id}/enable` | | `{ ok }` | -| POST | `/api/v1/propagation/{id}/disable` | | `{ ok }` | +| POST | `/api/v1/propagation/{id}/enable` | | `{ ok }` — for `local-prop`, starts PN serve + announce | +| POST | `/api/v1/propagation/{id}/disable` | | `{ ok }` — for `local-prop`, stops PN serve + announce | | POST | `/api/v1/propagation/{id}/preferred` | | `{ ok }` | | POST | `/api/v1/propagation/sync` | | `{ ok }` | | POST | `/api/v1/propagation/sync/cancel` | | `{ ok }` | @@ -226,7 +227,7 @@ Renderer calls `electronAPI.reticulum.*`; main process proxies to this API (sand `getStatus` / `onStatus` may include `interfaceIssueAlert` (TCP connect failures, TX queue drops, link-delivery timeouts, transport saturation / slow queries, **`bleBondRemoved`** stale RNode bonds, **`blePairingTimedOut`** OS passkey / TX-read timeouts). Per-entry latch timestamps use a **5-minute** stale window (`RETICULUM_INTERFACE_ISSUE_ALERT_STALE_MS`). Connection syncs **enabled** interface names via `syncInterfaceIssueScope` so disabling or removing an interface clears that name immediately and rejects re-latch from lagging log lines. Stopping the stack (or unexpected process exit) clears the tracker. -**`propagation_sync` WebSocket payload:** `{ active: boolean, progress: number, message: string | null }`. Progress uses 0–100 (Establishing ≈10, Offering ≈25, …, Complete ≈100). Sticky success after HaveAll emits `active:false, progress:100`; cancel/stall/failure emit `active:false, progress:0` (and must not emit a trailing 100). Sync `POST /api/v1/propagation/sync` may return `PROPAGATION_IDENTITY_UNKNOWN`, `PROPAGATION_TARGET_NOT_PN`, `PROPAGATION_PEERING_STAMP_FAILED`, or `LOCAL_PROPAGATION_SYNC_UNSUPPORTED`. +**`propagation_sync` WebSocket payload:** `{ active: boolean, progress: number, message: string | null }`. Progress uses 0–100 (Establishing ≈10, Offering ≈25, …, Complete ≈100). Sticky success after HaveAll emits `active:false, progress:100`; cancel/stall/failure emit `active:false, progress:0` (and must not emit a trailing 100). Sync `POST /api/v1/propagation/sync` may return `PROPAGATION_IDENTITY_UNKNOWN`, `PROPAGATION_TARGET_NOT_PN`, `PROPAGATION_PEERING_STAMP_FAILED`, `PROPAGATION_PEER_COST_EXCEEDS_MAX`, or `LOCAL_PROPAGATION_SYNC_UNSUPPORTED`. Add may return `PROPAGATION_OFFER_UNSUPPORTED` / probe timeout failures. SQLite chat history uses separate `db:*` handlers (`getReticulumMessages`, `saveReticulumMessage`, `searchReticulumMessages`, `deleteReticulumMessage`, destination upserts), not sidecar HTTP. Remote saved addresses / inbound policy and RRC room history also use dedicated `db:*` handlers (not sidecar HTTP). diff --git a/docs/reticulum.md b/docs/reticulum.md index 0ad400eee..23fd1d7f2 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -37,7 +37,7 @@ After changing interfaces on a live network, **restart the stack** so RNS picks | Topology | Best-effort graph from path-table next hops (not a full multi-hop trace) | | Map | Local RMAP v4 discovery map (heard opt-in interfaces with GPS); link to rmap.world for global view | | Nomad Network | Favourites / announces list (collapsible sidebar, default Favourites sub-tab) plus **My Pages** watched-folder hosting; **lazy-mount after first visit**; Micron (.mu) browser in a **dual-axis scroll shell**; **fit-width wrap default** with open-width toggle for ASCII pages; in-page navigation, back/forward, session page cache, `/file/` downloads, source toggle, and lxmf:// DM links; page/file errors humanized via `nomadPageErrorHumanize.ts`. Local hosting uses sibling [rsNomad](https://github.com/Colorado-Mesh/rsNomad) (`nomad-core`) for static `/page` + `/file` serving and `nomadnetwork.node` announces (no CGI). Choose a site root (`pages/`) or pages directory; FS watcher reloads routes; `nomad_serving_enabled` auto-restores after stack start. | -| Propagation | Preferred node, per-node **Sync messages**, rename/delete remote nodes, **Discovered on network** (Add / Add & prefer), optional **local propagation inbox**, configurable **auto-sync interval** | +| Propagation | Preferred node, per-node **Sync messages**, rename/delete remote nodes, **Discovered on network** (Add / Add & prefer with `/offer` probe), optional **local PN hosting**, configurable **auto-sync interval**, Network **Advanced PN hosting** policy | | Diagnostics | Reticulum-native interface / path / LXMF health and config audit (`reticulum/*` rows only on this tab; LoRa Hop Goblins and foreign-LoRa tables are Meshtastic/MeshCore-scoped) | | Admin | RNode firmware flasher (Web Serial), stack factory reset | | Sniffer / Stats | Reticulum packet log tab (`rawPacketLog.reticulum.*`) | @@ -233,7 +233,7 @@ When multiple enabled local RNode interfaces are connected, the interface list s - **Config validate:** Electron IPC `reticulum:validateConfig` → one-shot sidecar `validate-config --json` against `userData/reticulum/config` - **Announces:** interval (`announce_interval_sec`, 0–86400; default **3600** s / 1 h when unset; `0` = startup-only) persisted in rnsd config. The live sidecar sends an **LXMF delivery** announce shortly after stack start and on that interval (Ratspeak/lxmd parity). **Announce now** (`POST /api/v1/announces`) forces an immediate delivery announce. **Clear announces** (`DELETE /api/v1/announces`) clears the stub peer cache; the live path table may refill on the next peer refresh. Per-interface `announce_interval_min` (RMAP/discoverable interfaces) is separate. - **Inbound LXMF:** the sidecar registers `lxmf.delivery` with the transport (`RegisterDestination` + `LinkManager`) and feeds decrypted link/resource payloads into the delivery callback (WS `lxmf_message`). Without this registration, peer DMs never appear in Chat even when paths exist. -- **Propagation:** preferred node for offline DMs, per-node **Sync messages**, add remote propagation nodes by 32-character `lxmf.propagation` hash or from the **Discovered on network** list (heard PN announces; Add / Add & prefer — never silent auto-add), **rename** / **delete** remote nodes, optional **local propagation inbox**, **auto-sync interval** (`auto_sync_interval_sec`; `0` disables periodic sync; interval measured from last _successful_ sync with a short failure cooldown). Remote sync **always sends an LXMF delivery announce** then settles briefly (~2s) before Establishing so the PN has a reverse path for LRPROOF, **re-requests the forward path** (does not reuse a possibly stale hop count), pins/persists PN identity during Establishing (avoids announce-flood eviction), resolves identity+path before Establishing, rejects non-PN destinations (`PROPAGATION_TARGET_NOT_PN`), requires a peering stamp when cost > 0, treats HaveAll/Complete as success (not failure), surfaces `NoLinkProof` when establish stalls without a proof, and the renderer cancels Establishing-only stalls (~45s) plus a hard ceiling (~180s) via `reticulumPropagationSync.ts` without overwriting sidecar failure keys. +- **Propagation:** preferred node for offline DMs, per-node **Sync messages**, add remote propagation nodes by 32-character `lxmf.propagation` hash or from the **Discovered on network** list (heard PN announces; Add / Add & prefer — never silent auto-add), **rename** / **delete** remote nodes, optional **local PN hosting** (announce + `/offer`/`/get`), Network **Advanced PN hosting** policy (`peering_cost`, `max_peering_cost`, autopeer, stamps, storage), Add-time `/offer` probe, **auto-sync interval** (`auto_sync_interval_sec`; `0` disables periodic sync; interval measured from last _successful_ sync with a short failure cooldown). Remote sync **always sends an LXMF delivery announce** then settles briefly (~2s) before Establishing so the PN has a reverse path for LRPROOF, **re-requests the forward path** (does not reuse a possibly stale hop count), pins/persists PN identity during Establishing (avoids announce-flood eviction), resolves identity+path before Establishing, rejects non-PN destinations (`PROPAGATION_TARGET_NOT_PN`), requires a peering stamp when cost > 0, treats HaveAll/Complete as success (not failure), surfaces `NoLinkProof` when establish stalls without a proof, and the renderer cancels Establishing-only stalls (~45s) plus a hard ceiling (~180s) via `reticulumPropagationSync.ts` without overwriting sidecar failure keys. --- diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 2c70ed93f..8071de55c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1120,7 +1120,15 @@ Unrecognized codes pass through unchanged. - Transfer-phase hangs use a renderer hard ceiling (~180s) plus lxmf-core’s own timeouts. - Auto-sync interval counts from the last _successful_ sync; failed attempts only apply a short cooldown (~2 min) so they do not postpone the next scheduled sync forever. -**Fix**: Prefer a discovered `lxmf.propagation` node, wait for an announce/path, retry **Sync** (or **Announce now** then Sync), and check Device logs for `[propagation-sync]` / offer errors. +**Fix**: Prefer a discovered `lxmf.propagation` node, wait for an announce/path, retry **Sync** (or **Announce now** then Sync), and check Device logs for `[propagation-sync]` / offer errors. If Add fails with **offer unsupported**, the destination does not speak LXMF `/offer`. If Sync/Add fails with **peering cost exceeds max**, raise **Network → Advanced PN hosting → Max peering cost**. + +### Reticulum local PN hosting not discoverable + +**Symptoms**: Local Host propagation node is enabled but peers never hear your PN announce / cannot `/offer` or `/get`. + +**Cause**: Hosting requires a live stack with identity signing key; enable starts `lxmf.propagation` LinkManager + announce loop. + +**Fix**: Confirm sidecar is running, identity is configured, **Network → Propagation → Host propagation node** is Enabled, and check logs for `[propagation-serve]` / `[propagation-announce]`. Tune announce interval under **Advanced PN hosting**. ### MeshCore Colorado Mesh / LetsMesh won't connect after upgrade @@ -1171,7 +1179,7 @@ Export for GitHub (`reticulum.sidecar.interfaceIssueAlert`, link-timeout counts) 1. Open **Network → Propagation** (Chat notice **Set up propagation** jumps there). 2. Add a **32-character LXMF destination hash** from whoever runs the propagation node you trust. 3. Set **Preferred** (manual mode) or leave **Auto** when multiple nodes are listed. -4. **Local propagation only** is this device’s offline inbox — it does **not** replace a remote propagation node for peers you cannot reach directly. Preferring Local shows a warning toast; Chat still treats local-only as “no remote PN.” +4. **Local propagation hosting** stores messages for peers that sync with you — it does **not** replace a remote propagation node for peers you cannot reach directly. Preferring Local shows a warning toast; Chat still treats local-only as “no remote PN.” **Stale path + Failed via TCP:** When a path exists, mesh-client tries **Direct** first. If Direct fails and a preferred **remote** PN is configured, the sidecar retries once via that PN (Ratspeak-style store-and-forward). Without a remote preferred PN, the row stays **Failed** even if Ratspeak on the same machine deposits successfully. diff --git a/reticulum-sidecar/src/api/mod.rs b/reticulum-sidecar/src/api/mod.rs index 8305e96f9..05fac2674 100644 --- a/reticulum-sidecar/src/api/mod.rs +++ b/reticulum-sidecar/src/api/mod.rs @@ -139,6 +139,10 @@ pub fn router(stack: Arc) -> Router { "/api/v1/propagation/auto-sync-interval", post(propagation::set_propagation_auto_sync_interval), ) + .route( + "/api/v1/propagation/hosting-policy", + post(propagation::set_pn_hosting_policy), + ) .route( "/api/v1/propagation/{id}/enable", post(propagation::enable_propagation), diff --git a/reticulum-sidecar/src/api/propagation.rs b/reticulum-sidecar/src/api/propagation.rs index 10bc64706..7e25f5bd9 100644 --- a/reticulum-sidecar/src/api/propagation.rs +++ b/reticulum-sidecar/src/api/propagation.rs @@ -20,6 +20,8 @@ pub struct PropagationSyncBody { pub struct AddPropagationBody { pub destination_hash: String, pub name: Option, + #[serde(default)] + pub skip_probe: bool, } #[derive(Debug, Deserialize)] @@ -27,12 +29,22 @@ pub struct RenamePropagationBody { pub name: String, } +pub async fn set_pn_hosting_policy( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack.set_pn_hosting_policy(body).await { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + pub async fn add_propagation_node( State(stack): State>, Json(body): Json, ) -> Json { match stack - .add_propagation_node(&body.destination_hash, body.name) + .add_propagation_node(&body.destination_hash, body.name, body.skip_probe) .await { Ok(res) => Json(res), diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 56814ef6b..9c0205ed3 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -48,7 +48,11 @@ use super::packet_log::{ }; use super::path_speed; use super::persistence::PersistedState; +use super::pn_hosting_apply::{apply_pn_hosting_policy_to_node, apply_pn_hosting_policy_to_router}; +use super::pn_hosting_policy::PnHostingPolicy; +use super::propagation_announce::PropagationAnnounceLoop; use super::propagation_bridge::PropagationBridge; +use super::propagation_serve::PropagationServeHandle; use super::rncp_transfer::RncpTransferManager; use super::rnsh_session::RnshSessionManager; use super::rrc_defaults::RRC_HUB_ASPECT; @@ -93,6 +97,9 @@ pub struct LiveBridge { display_name_cache: Arc>>, outbound: Arc>, propagation: Arc, + prop_serve: Arc, + prop_announce: Arc, + pn_hosting_policy: Arc>, /// Per-run cancel token; replaced on each new sync so stale emitters cannot reset it. sync_cancel: Mutex>, /// Generation for the active sync emitter; stale emitters must not cancel/clear pins. @@ -274,7 +281,12 @@ impl LiveBridge { contacts_to_name_map(&state.contacts) })); + let pn_hosting_policy = { + let state = inner.read().await; + state.pn_hosting_policy.clone() + }; let mut router = LxmRouter::new(lxmf_core::router::RouterConfig::default()); + apply_pn_hosting_policy_to_router(&mut router, &pn_hosting_policy); router.set_transport(handle.transport_tx.clone()); let cache_for_cb = peer_via_cache.clone(); @@ -404,7 +416,11 @@ impl LiveBridge { lxmf_propagation_dest_hash, storage_dir.join("propagation"), &identity, + &pn_hosting_policy, )?), + prop_serve: Arc::new(PropagationServeHandle::new()), + prop_announce: Arc::new(PropagationAnnounceLoop::new()), + pn_hosting_policy: Arc::new(Mutex::new(pn_hosting_policy)), sync_cancel: Mutex::new(Arc::new(AtomicBool::new(false))), sync_run_id: Arc::new(AtomicU64::new(0)), discovered_propagation: Arc::new(Mutex::new(HashMap::new())), @@ -1089,6 +1105,7 @@ impl LiveBridge { /// Register handler for LXMF propagation-node announces (`lxmf.propagation`). /// /// Upserts an in-memory discovered list (not auto-added to configured PNs). + /// When local hosting + autopeer are on, also feeds `LxmRouter::autopeer`. pub fn register_propagation_announce_handler(&self) { const LXMF_PROPAGATION_ASPECT: &str = "lxmf.propagation"; const MAX_DISCOVERED_PROPAGATION: usize = 200; @@ -1096,6 +1113,9 @@ impl LiveBridge { let event_tx = self.event_tx.clone(); let discovered = Arc::clone(&self.discovered_propagation); let outbound = Arc::clone(&self.outbound); + let router = Arc::clone(&self.router); + let propagation = Arc::clone(&self.propagation); + let pn_hosting_policy = Arc::clone(&self.pn_hosting_policy); tokio::spawn(async move { let (callback_tx, mut callback_rx) = tokio::sync::mpsc::channel::(64); @@ -1175,6 +1195,28 @@ impl LiveBridge { let frame = serde_json::json!({ "type": "propagation.discovered", "payload": payload }); let _ = event_tx.send(frame.to_string()); + + // Autopeer only while hosting a local PN (lxmd parity). + if propagation.is_local_serving() { + let autopeer_on = pn_hosting_policy + .lock() + .ok() + .map(|p| p.autopeer) + .unwrap_or(true); + if autopeer_on && parsed.node_state { + let mut router = router.lock().await; + let _ = router.autopeer(lxmf_core::router::AutopeerCandidate { + destination_hash: evt.destination_hash, + timebase: parsed.timebase as f64, + transfer_limit: Some(parsed.transfer_limit as f64), + sync_limit: Some(parsed.sync_limit as f64), + stamp_cost: Some(parsed.stamp_cost), + stamp_flexibility: Some(parsed.stamp_flex), + peering_cost: Some(parsed.peering_cost), + hops: Some(evt.hops), + }); + } + } } }); } @@ -1983,6 +2025,131 @@ impl LiveBridge { pub async fn set_local_propagation_serving(&self, enabled: bool) { let mut router = self.router.lock().await; self.propagation.set_local_serving(enabled, &mut router); + drop(router); + + if enabled { + let policy = self + .pn_hosting_policy + .lock() + .ok() + .map(|p| p.clone()) + .unwrap_or_default(); + if let Err(e) = self.prop_serve.start( + self.handle.transport_tx.clone(), + &self.identity, + self.propagation.local_dest_hash_bytes(), + self.propagation.local_node(), + ) { + tracing::error!(target: "propagation-serve", "failed to start serve: {e}"); + let mut router = self.router.lock().await; + self.propagation.set_local_serving(false, &mut router); + return; + } + self.prop_announce.start( + self.handle.transport_tx.clone(), + self.identity.clone(), + self.propagation.local_dest_hash_bytes(), + Arc::clone(&self.pn_hosting_policy), + policy.announce_at_start, + ); + } else { + self.prop_announce.stop(); + self.prop_serve.stop(); + } + } + + pub async fn apply_pn_hosting_policy(&self, policy: &PnHostingPolicy) { + if let Ok(mut slot) = self.pn_hosting_policy.lock() { + *slot = policy.clone(); + } + { + let mut router = self.router.lock().await; + apply_pn_hosting_policy_to_router(&mut router, policy); + } + if let Ok(mut node) = self.propagation.local_node().lock() { + apply_pn_hosting_policy_to_node(&mut node, policy); + } + // Restart announce loop with updated interval / name when serving. + if self.propagation.is_local_serving() { + self.prop_announce.start( + self.handle.transport_tx.clone(), + self.identity.clone(), + self.propagation.local_dest_hash_bytes(), + Arc::clone(&self.pn_hosting_policy), + false, + ); + } + } + + /// Lightweight `/offer` capability probe before persisting a remote PN. + /// + /// Returns `Ok(())` when the remote answers `/offer` (including LXMF offer + /// errors that prove the handler ran). Hard-fails with + /// `PROPAGATION_OFFER_UNSUPPORTED` only when the offer response is + /// unrecognized (`Unknown`). + pub async fn probe_propagation_offer(&self, destination_hash: &str) -> Result<(), String> { + let dest_hex = destination_hash.trim().to_lowercase(); + let hash = parse_hash16(&dest_hex)?; + self.cancel_propagation_sync().await; + self.rehydrate_propagation_identities_from_persisted(); + let identity_ok = self.ensure_identity_for_direct(&dest_hex).await; + let _path_ok = self.ensure_path_for_direct(&dest_hex, true).await; + let identity_known_after = self + .outbound + .lock() + .map(|d| d.identity_known_for(&dest_hex)) + .unwrap_or(false); + if !identity_ok || !identity_known_after { + return Err("PROPAGATION_IDENTITY_UNKNOWN".into()); + } + let target_class = self.classify_propagation_sync_target(&dest_hex).await; + if target_class == "delivery" || target_class == "other" { + return Err("PROPAGATION_TARGET_NOT_PN".into()); + } + let peering = self.resolve_propagation_peering(&dest_hex).await?; + if !self.propagation.start_sync(hash, Some(peering)) { + return Err("PROPAGATION_OFFER_PROBE_FAILED".into()); + } + + let deadline = Instant::now() + Duration::from_secs(45); + loop { + if Instant::now() >= deadline { + self.cancel_propagation_sync().await; + return Err("PROPAGATION_OFFER_PROBE_TIMEOUT".into()); + } + tokio::time::sleep(Duration::from_millis(200)).await; + if let Some(err) = self.propagation.last_offer_error() { + self.cancel_propagation_sync().await; + return if err == "Unknown" { + Err("PROPAGATION_OFFER_UNSUPPORTED".into()) + } else { + // Handler ran (NoIdentity / InvalidKey / …) — path exists. + Ok(()) + }; + } + if let Some(err) = self.propagation.last_establish_error() { + self.cancel_propagation_sync().await; + return Err(format!("propagation establish failed: {err}")); + } + let progress = self.propagation.sync_progress(); + // Offering / later stages prove /offer was accepted enough to proceed. + if progress >= 25.0 { + self.cancel_propagation_sync().await; + return Ok(()); + } + if !self.propagation.sync_active() && progress <= 0.0 { + if let Some(ok) = self.propagation.last_finished_ok() { + self.cancel_propagation_sync().await; + return if ok { + Ok(()) + } else if self.propagation.last_offer_error() == Some("Unknown") { + Err("PROPAGATION_OFFER_UNSUPPORTED".into()) + } else { + Err("PROPAGATION_OFFER_PROBE_FAILED".into()) + }; + } + } + } } pub fn propagation_local_stats(&self) -> (usize, usize) { @@ -2134,6 +2301,15 @@ impl LiveBridge { .pn_announce_peering_cost(destination_hex) .await .unwrap_or(lxmf_core::constants::PEERING_COST); + let max_cost = self + .pn_hosting_policy + .lock() + .ok() + .map(|p| p.max_peering_cost) + .unwrap_or(lxmf_core::constants::MAX_PEERING_COST); + if peering_cost > max_cost { + return Err("PROPAGATION_PEER_COST_EXCEEDS_MAX".into()); + } let precomputed = if peering_cost == 0 { Some(Vec::new()) } else { diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index 86b1e1c97..97bfdcbf8 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -16,6 +16,9 @@ mod nomad_timeouts; mod packet_log; mod path_speed; mod persistence; +#[cfg(feature = "rns-stack")] +mod pn_hosting_apply; +mod pn_hosting_policy; pub mod rf_profiles; mod rmap_discovery; mod rrc_codec; @@ -33,8 +36,12 @@ mod lxmf_delivery; #[cfg(feature = "rns-stack")] mod nomad_server; #[cfg(feature = "rns-stack")] +mod propagation_announce; +#[cfg(feature = "rns-stack")] mod propagation_bridge; #[cfg(feature = "rns-stack")] +mod propagation_serve; +#[cfg(feature = "rns-stack")] mod rncp_transfer; #[cfg(feature = "rns-stack")] mod rnsh_session; @@ -50,6 +57,7 @@ use std::sync::Arc; pub use config::{ImportMode, ImportResult, StackSettings, UpdateInterfacePatch}; use packet_log::{MAX_WIRE_PACKET_LOG, PacketLogBuffer, WirePacketRow}; use persistence::PersistedState; +pub use pn_hosting_policy::PnHostingPolicy; use tokio::sync::{Mutex, RwLock, broadcast}; pub use types::{ AddInterfaceRequest, ContactRow, DiscoveredPropagationRow, InterfaceRow, LxmfReactionRequest, @@ -848,6 +856,7 @@ impl StackHandle { let inner = self.inner.read().await; let preferred_id = inner.preferred_propagation_id.clone(); let auto_sync_interval_sec = inner.auto_sync_interval_sec; + let pn_hosting_policy = inner.pn_hosting_policy.clone(); #[cfg(feature = "rns-stack")] let local_stats = if let Some(live) = &self.live { let (count, bytes) = live.propagation_local_stats(); @@ -905,6 +914,7 @@ impl StackHandle { "propagation": propagation, "preferred_id": preferred_id, "auto_sync_interval_sec": auto_sync_interval_sec, + "pn_hosting_policy": pn_hosting_policy, }) } @@ -943,6 +953,21 @@ impl StackHandle { Ok(()) } + pub async fn set_pn_hosting_policy(&self, policy: PnHostingPolicy) -> Result<(), String> { + let policy = { + let mut inner = self.inner.write().await; + inner.set_pn_hosting_policy(policy)?; + let policy = inner.pn_hosting_policy.clone(); + inner.save(&self.config_dir, &self.storage_dir)?; + policy + }; + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + live.apply_pn_hosting_policy(&policy).await; + } + Ok(()) + } + pub async fn start_propagation_sync(&self, propagation_id: &str) -> Result<(), String> { let prop_hash = { let inner = self.inner.read().await; @@ -1032,6 +1057,7 @@ impl StackHandle { &self, destination_hash: &str, name: Option, + skip_probe: bool, ) -> Result { let hash = destination_hash.trim().to_lowercase(); // Prefer live known key / discovered announce metadata before persist. @@ -1057,9 +1083,16 @@ impl StackHandle { } #[cfg(not(feature = "rns-stack"))] { + let _ = skip_probe; (None, None) } }; + #[cfg(feature = "rns-stack")] + if !skip_probe { + if let Some(live) = &self.live { + live.probe_propagation_offer(&hash).await?; + } + } let mut inner = self.inner.write().await; let mut row = inner.add_propagation_node(destination_hash, name)?; if pub_hex.is_some() || id_hex.is_some() { diff --git a/reticulum-sidecar/src/stack/persistence.rs b/reticulum-sidecar/src/stack/persistence.rs index 7a1c7adac..6f1988673 100644 --- a/reticulum-sidecar/src/stack/persistence.rs +++ b/reticulum-sidecar/src/stack/persistence.rs @@ -6,6 +6,7 @@ use uuid::Uuid; use serde::Deserialize; +use super::pn_hosting_policy::PnHostingPolicy; use super::types::{ AddInterfaceRequest, ContactRow, InterfaceRow, LxmfReactionRequest, LxmfSendRequest, NomadNodeRow, PeerRow, PropagationRow, RrcHubRow, StackIdentity, @@ -28,6 +29,8 @@ pub struct PersistedState { pub primary_local_serial_interface_id: Option, pub propagation_sync: serde_json::Value, pub auto_sync_interval_sec: u32, + /// LXMF local PN hosting / peering policy (defaults match rsLXMF / lxmd). + pub pn_hosting_policy: PnHostingPolicy, pub nomad_nodes: Vec, pub rrc_hubs: Vec, /// User preference: start Nomad page hosting when the live stack is up. @@ -77,6 +80,7 @@ impl PersistedState { primary_local_serial_interface_id: None, propagation_sync: serde_json::Value::Null, auto_sync_interval_sec: 3600, + pn_hosting_policy: PnHostingPolicy::default(), nomad_nodes: Vec::new(), rrc_hubs: Vec::new(), nomad_serving_enabled: false, @@ -96,7 +100,7 @@ impl PersistedState { if self.propagation.is_empty() { self.propagation.push(PropagationRow { id: "local-prop".into(), - name: "Local propagation (offline inbox)".to_string(), + name: "Local propagation node".to_string(), hops: Some(0), enabled: false, status: "unknown".into(), @@ -391,6 +395,12 @@ impl PersistedState { self.auto_sync_interval_sec = sec; } + pub fn set_pn_hosting_policy(&mut self, policy: PnHostingPolicy) -> Result<(), String> { + let policy = policy.sanitized()?; + self.pn_hosting_policy = policy; + Ok(()) + } + pub fn upsert_nomad_node( &mut self, hash: &str, @@ -770,7 +780,7 @@ impl serde::Serialize for PersistedState { S: serde::Serializer, { use serde::ser::SerializeStruct; - let mut s = serializer.serialize_struct("PersistedState", 24)?; + let mut s = serializer.serialize_struct("PersistedState", 25)?; s.serialize_field("identity", &self.identity)?; s.serialize_field("interfaces", &self.interfaces)?; s.serialize_field("contacts", &self.contacts)?; @@ -786,6 +796,7 @@ impl serde::Serialize for PersistedState { )?; s.serialize_field("propagation_sync", &self.propagation_sync)?; s.serialize_field("auto_sync_interval_sec", &self.auto_sync_interval_sec)?; + s.serialize_field("pn_hosting_policy", &self.pn_hosting_policy)?; s.serialize_field("nomad_nodes", &self.nomad_nodes)?; s.serialize_field("rrc_hubs", &self.rrc_hubs)?; s.serialize_field("nomad_serving_enabled", &self.nomad_serving_enabled)?; @@ -833,6 +844,8 @@ impl<'de> serde::Deserialize<'de> for PersistedState { #[serde(default)] auto_sync_interval_sec: u32, #[serde(default)] + pn_hosting_policy: PnHostingPolicy, + #[serde(default)] nomad_nodes: Vec, #[serde(default)] rrc_hubs: Vec, @@ -875,6 +888,7 @@ impl<'de> serde::Deserialize<'de> for PersistedState { raw.propagation_sync }, auto_sync_interval_sec: raw.auto_sync_interval_sec, + pn_hosting_policy: raw.pn_hosting_policy, nomad_nodes: raw.nomad_nodes, rrc_hubs: raw.rrc_hubs, nomad_serving_enabled: raw.nomad_serving_enabled, diff --git a/reticulum-sidecar/src/stack/pn_hosting_apply.rs b/reticulum-sidecar/src/stack/pn_hosting_apply.rs new file mode 100644 index 000000000..62209453d --- /dev/null +++ b/reticulum-sidecar/src/stack/pn_hosting_apply.rs @@ -0,0 +1,48 @@ +//! Apply [`PnHostingPolicy`] to a live `LxmRouter` + `PropagationNode`. + +use lxmf_core::peer::LxmPeer; +use lxmf_core::propagation_node::PropagationNode; +use lxmf_core::router::LxmRouter; + +use super::pn_hosting_policy::PnHostingPolicy; + +pub fn apply_pn_hosting_policy_to_router(router: &mut LxmRouter, policy: &PnHostingPolicy) { + router.set_autopeer(policy.autopeer); + router.set_max_peers(policy.max_peers); + router.set_propagation_limit(policy.propagation_limit_kb); + router.set_stamp_requirements(policy.propagation_stamp_cost, policy.propagation_stamp_flex); + router.set_message_storage_limit(Some(policy.message_storage_limit_bytes())); + router.set_authentication(policy.auth_required); + router.set_enforce_stamps(policy.enforce_stamps); + router.set_enforce_ratchets(policy.enforce_ratchets); + + router.config.sync_limit_kb = policy.sync_limit_kb; + router.config.delivery_limit_kb = policy.delivery_limit_kb; + router.config.ext.peering_cost = policy.peering_cost; + router.config.ext.max_peering_cost = policy.max_peering_cost; + router.config.ext.autopeer_maxdepth = policy.autopeer_maxdepth; + router.config.ext.from_static_only = policy.from_static_only; + router.config.ext.name = policy.node_name.clone(); + + router.static_peers.clear(); + for peer in &policy.static_peers { + if let Ok(bytes) = hex::decode(peer) + && let Ok(hash) = <[u8; 16]>::try_from(bytes.as_slice()) + { + if !router.static_peers.contains(&hash) { + router.static_peers.push(hash); + } + router + .peers + .entry(hash) + .or_insert_with(|| LxmPeer::new(hash)); + } + } +} + +pub fn apply_pn_hosting_policy_to_node(node: &mut PropagationNode, policy: &PnHostingPolicy) { + node.set_min_stamp_cost(policy.min_stamp_cost()); + node.set_peering_cost(policy.peering_cost); + node.set_max_storage(policy.message_storage_limit_bytes()); + node.set_max_message_size(policy.propagation_limit_kb.saturating_mul(1024)); +} diff --git a/reticulum-sidecar/src/stack/pn_hosting_policy.rs b/reticulum-sidecar/src/stack/pn_hosting_policy.rs new file mode 100644 index 000000000..d1cf2f932 --- /dev/null +++ b/reticulum-sidecar/src/stack/pn_hosting_policy.rs @@ -0,0 +1,205 @@ +//! Persisted LXMF propagation-node hosting / peering policy. + +use serde::{Deserialize, Serialize}; + +/// Defaults match rsLXMF `RouterConfig` / `RouterConfigExt` / lxmd `[propagation]`. +pub const DEFAULT_PEERING_COST: u8 = 18; +pub const DEFAULT_MAX_PEERING_COST: u8 = 26; +pub const DEFAULT_AUTOPEER: bool = true; +pub const DEFAULT_AUTOPEER_MAXDEPTH: usize = 4; +pub const DEFAULT_MAX_PEERS: usize = 20; +pub const DEFAULT_PROPAGATION_STAMP_COST: u8 = 16; +pub const DEFAULT_PROPAGATION_STAMP_FLEX: u8 = 3; +pub const DEFAULT_MESSAGE_STORAGE_LIMIT_MB: u32 = 256; +pub const DEFAULT_PROPAGATION_LIMIT_KB: usize = 256; +pub const DEFAULT_SYNC_LIMIT_KB: usize = 10_240; +pub const DEFAULT_DELIVERY_LIMIT_KB: usize = 1000; +pub const DEFAULT_PN_ANNOUNCE_INTERVAL_SEC: u32 = 360; +pub const DEFAULT_ANNOUNCE_AT_START: bool = true; + +const MAX_AUTOPEER_MAXDEPTH: usize = 64; +const MAX_MAX_PEERS: usize = 256; +const MAX_STORAGE_MB: u32 = 10_240; +const MAX_LIMIT_KB: usize = 102_400; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +#[allow(clippy::struct_excessive_bools)] // mirrors independent LXMF router prefs +pub struct PnHostingPolicy { + pub peering_cost: u8, + pub max_peering_cost: u8, + pub autopeer: bool, + pub autopeer_maxdepth: usize, + pub max_peers: usize, + pub propagation_stamp_cost: u8, + pub propagation_stamp_flex: u8, + pub message_storage_limit_mb: u32, + pub propagation_limit_kb: usize, + pub sync_limit_kb: usize, + pub delivery_limit_kb: usize, + pub from_static_only: bool, + pub auth_required: bool, + pub enforce_stamps: bool, + pub enforce_ratchets: bool, + pub static_peers: Vec, + pub node_name: Option, + pub pn_announce_interval_sec: u32, + pub announce_at_start: bool, +} + +impl Default for PnHostingPolicy { + fn default() -> Self { + Self { + peering_cost: DEFAULT_PEERING_COST, + max_peering_cost: DEFAULT_MAX_PEERING_COST, + autopeer: DEFAULT_AUTOPEER, + autopeer_maxdepth: DEFAULT_AUTOPEER_MAXDEPTH, + max_peers: DEFAULT_MAX_PEERS, + propagation_stamp_cost: DEFAULT_PROPAGATION_STAMP_COST, + propagation_stamp_flex: DEFAULT_PROPAGATION_STAMP_FLEX, + message_storage_limit_mb: DEFAULT_MESSAGE_STORAGE_LIMIT_MB, + propagation_limit_kb: DEFAULT_PROPAGATION_LIMIT_KB, + sync_limit_kb: DEFAULT_SYNC_LIMIT_KB, + delivery_limit_kb: DEFAULT_DELIVERY_LIMIT_KB, + from_static_only: false, + auth_required: false, + enforce_stamps: false, + enforce_ratchets: false, + static_peers: Vec::new(), + node_name: None, + pn_announce_interval_sec: DEFAULT_PN_ANNOUNCE_INTERVAL_SEC, + announce_at_start: DEFAULT_ANNOUNCE_AT_START, + } + } +} + +impl PnHostingPolicy { + pub fn validate(&self) -> Result<(), String> { + if self.peering_cost > self.max_peering_cost { + return Err("peering_cost_exceeds_max".into()); + } + if self.propagation_stamp_flex > self.propagation_stamp_cost { + return Err("stamp_flex_exceeds_cost".into()); + } + if self.autopeer_maxdepth > MAX_AUTOPEER_MAXDEPTH { + return Err("autopeer_maxdepth_out_of_range".into()); + } + if self.max_peers == 0 || self.max_peers > MAX_MAX_PEERS { + return Err("max_peers_out_of_range".into()); + } + if self.message_storage_limit_mb == 0 || self.message_storage_limit_mb > MAX_STORAGE_MB { + return Err("message_storage_limit_out_of_range".into()); + } + if self.propagation_limit_kb == 0 || self.propagation_limit_kb > MAX_LIMIT_KB { + return Err("propagation_limit_out_of_range".into()); + } + if self.sync_limit_kb == 0 || self.sync_limit_kb > MAX_LIMIT_KB { + return Err("sync_limit_out_of_range".into()); + } + if self.delivery_limit_kb == 0 || self.delivery_limit_kb > MAX_LIMIT_KB { + return Err("delivery_limit_out_of_range".into()); + } + if self.pn_announce_interval_sec > 86_400 { + return Err("pn_announce_interval_out_of_range".into()); + } + for peer in &self.static_peers { + validate_static_peer_hash(peer)?; + } + if let Some(name) = &self.node_name { + let trimmed = name.trim(); + if trimmed.chars().any(char::is_control) { + return Err("node_name_invalid".into()); + } + if trimmed.chars().count() > 128 { + return Err("node_name_too_long".into()); + } + } + Ok(()) + } + + /// Clamp and normalize; returns a validated policy or an error for semantic violations. + pub fn sanitized(mut self) -> Result { + self.static_peers = self + .static_peers + .into_iter() + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect(); + if let Some(name) = self.node_name.take() { + let trimmed = name.trim().to_string(); + self.node_name = if trimmed.is_empty() { + None + } else { + Some(trimmed) + }; + } + self.validate()?; + Ok(self) + } + + pub fn message_storage_limit_bytes(&self) -> usize { + (self.message_storage_limit_mb as usize).saturating_mul(1024 * 1024) + } + + pub fn min_stamp_cost(&self) -> u8 { + self.propagation_stamp_cost + .saturating_sub(self.propagation_stamp_flex) + } +} + +fn validate_static_peer_hash(hash: &str) -> Result<(), String> { + let trimmed = hash.trim().to_lowercase(); + if trimmed.len() != 32 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!("static_peer_invalid:{trimmed}")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_validate() { + assert!(PnHostingPolicy::default().validate().is_ok()); + } + + #[test] + fn rejects_peering_cost_above_max() { + let policy = PnHostingPolicy { + peering_cost: 30, + max_peering_cost: 26, + ..Default::default() + }; + assert_eq!(policy.validate().unwrap_err(), "peering_cost_exceeds_max"); + } + + #[test] + fn rejects_bad_static_peer() { + let policy = PnHostingPolicy { + static_peers: vec!["abcd".into()], + ..Default::default() + }; + assert!( + policy + .validate() + .unwrap_err() + .starts_with("static_peer_invalid:") + ); + } + + #[test] + fn serde_round_trip_defaults() { + let json = serde_json::to_string(&PnHostingPolicy::default()).unwrap(); + let parsed: PnHostingPolicy = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, PnHostingPolicy::default()); + } + + #[test] + fn serde_missing_fields_use_defaults() { + let parsed: PnHostingPolicy = serde_json::from_str("{}").unwrap(); + assert_eq!(parsed.peering_cost, DEFAULT_PEERING_COST); + assert_eq!(parsed.max_peering_cost, DEFAULT_MAX_PEERING_COST); + assert!(parsed.autopeer); + } +} diff --git a/reticulum-sidecar/src/stack/propagation_announce.rs b/reticulum-sidecar/src/stack/propagation_announce.rs new file mode 100644 index 000000000..3c07e4255 --- /dev/null +++ b/reticulum-sidecar/src/stack/propagation_announce.rs @@ -0,0 +1,186 @@ +//! Periodic `lxmf.propagation` announces for local PN hosting. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bytes::Bytes; +use lxmf_core::handlers::{PropagationNodeAnnounceData, get_propagation_node_app_data}; +use rns_identity::announce::AnnounceData; +use rns_identity::identity::Identity; +use rns_transport::messages::{OutboundRequest, TransportMessage}; +use rns_wire::context::PacketContext; +use rns_wire::flags::{DestinationType, HeaderType, PacketFlags, PacketType, TransportType}; +use rns_wire::header::PacketHeader; +use tokio::sync::mpsc; + +use super::pn_hosting_policy::PnHostingPolicy; +use super::propagation_serve::LXMF_PROPAGATION_APP; + +pub fn build_propagation_announce_packet( + identity: &Identity, + propagation_dest_hash: [u8; 16], + policy: &PnHostingPolicy, + node_state: bool, +) -> Result, String> { + let mut pn_data = PropagationNodeAnnounceData::new( + node_state && !policy.from_static_only, + policy.propagation_limit_kb as u64, + policy.sync_limit_kb as u64, + policy.propagation_stamp_cost, + policy.propagation_stamp_flex, + policy.peering_cost, + ); + if let Some(ref name) = policy.node_name { + pn_data.set_name(name); + } + let app_data = get_propagation_node_app_data(&pn_data); + let announce = AnnounceData::create( + identity, + LXMF_PROPAGATION_APP, + Some(app_data.as_slice()), + None, + ) + .map_err(|e| format!("Failed to create propagation announce: {e}"))?; + let flags = PacketFlags { + header_type: HeaderType::Header1, + context_flag: false, + transport_type: TransportType::Broadcast, + destination_type: DestinationType::Single, + packet_type: PacketType::Announce, + }; + let header = PacketHeader { + flags, + hops: 0, + transport_id: None, + destination_hash: propagation_dest_hash, + context: PacketContext::None, + }; + let mut raw = header.pack(); + raw.extend_from_slice(&announce.pack()); + Ok(raw) +} + +pub async fn send_propagation_announce( + transport_tx: &mpsc::Sender, + identity: &Identity, + propagation_dest_hash: [u8; 16], + policy: &PnHostingPolicy, + node_state: bool, +) -> Result<(), String> { + let raw = + build_propagation_announce_packet(identity, propagation_dest_hash, policy, node_state)?; + transport_tx + .send(TransportMessage::Outbound(OutboundRequest { + raw: Bytes::from(raw), + destination_hash: propagation_dest_hash, + })) + .await + .map_err(|e| format!("Failed to send propagation announce: {e}")) +} + +pub struct PropagationAnnounceLoop { + running: AtomicBool, + stop_tx: Mutex>>, +} + +impl PropagationAnnounceLoop { + pub fn new() -> Self { + Self { + running: AtomicBool::new(false), + stop_tx: Mutex::new(None), + } + } + + pub fn stop(&self) { + self.running.store(false, Ordering::SeqCst); + if let Ok(mut slot) = self.stop_tx.lock() + && let Some(tx) = slot.take() + { + let _ = tx.send(()); + } + } + + pub fn start( + &self, + transport_tx: mpsc::Sender, + identity: Identity, + propagation_dest_hash: [u8; 16], + policy: Arc>, + announce_at_start: bool, + ) { + self.stop(); + let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel(); + if let Ok(mut slot) = self.stop_tx.lock() { + *slot = Some(stop_tx); + } + self.running.store(true, Ordering::SeqCst); + + tokio::spawn(async move { + if announce_at_start { + let snap = policy.lock().ok().map(|p| p.clone()).unwrap_or_default(); + if let Err(e) = send_propagation_announce( + &transport_tx, + &identity, + propagation_dest_hash, + &snap, + true, + ) + .await + { + tracing::warn!(target: "propagation-announce", "startup announce failed: {e}"); + } + } + + loop { + let interval_sec = policy + .lock() + .ok() + .map(|p| p.pn_announce_interval_sec) + .unwrap_or(360); + let wait = if interval_sec == 0 { + Duration::from_secs(360) + } else { + Duration::from_secs(u64::from(interval_sec)) + }; + tokio::select! { + _ = tokio::time::sleep(wait) => { + if interval_sec == 0 { + continue; + } + let snap = policy.lock().ok().map(|p| p.clone()).unwrap_or_default(); + if let Err(e) = send_propagation_announce( + &transport_tx, + &identity, + propagation_dest_hash, + &snap, + true, + ) + .await + { + tracing::warn!(target: "propagation-announce", "periodic announce failed: {e}"); + } + } + _ = &mut stop_rx => { + let snap = policy.lock().ok().map(|p| p.clone()).unwrap_or_default(); + let _ = send_propagation_announce( + &transport_tx, + &identity, + propagation_dest_hash, + &snap, + false, + ) + .await; + break; + } + } + } + }); + } +} + +impl Default for PropagationAnnounceLoop { + fn default() -> Self { + Self::new() + } +} diff --git a/reticulum-sidecar/src/stack/propagation_bridge.rs b/reticulum-sidecar/src/stack/propagation_bridge.rs index f16525139..f779d92c7 100644 --- a/reticulum-sidecar/src/stack/propagation_bridge.rs +++ b/reticulum-sidecar/src/stack/propagation_bridge.rs @@ -28,15 +28,19 @@ impl PropagationBridge { local_dest_hash: [u8; 16], storage_dir: PathBuf, identity: &Identity, + policy: &super::pn_hosting_policy::PnHostingPolicy, ) -> Result { std::fs::create_dir_all(&storage_dir).map_err(|e| e.to_string())?; + let node_config = PropagationNodeConfig { + max_storage: policy.message_storage_limit_bytes(), + max_message_age: lxmf_core::constants::MESSAGE_EXPIRY, + min_stamp_cost: policy.min_stamp_cost(), + peering_cost: policy.peering_cost, + max_message_size: policy.propagation_limit_kb.saturating_mul(1024), + }; let local_node = Arc::new(Mutex::new( - PropagationNode::with_storage( - PropagationNodeConfig::default(), - local_dest_hash, - storage_dir, - ) - .map_err(|e| format!("propagation storage init: {e}"))?, + PropagationNode::with_storage(node_config, local_dest_hash, storage_dir) + .map_err(|e| format!("propagation storage init: {e}"))?, )); let mut sync_task = PropagationSyncTask::with_shared_node(transport_tx, local_node.clone()); let signing_key = identity @@ -52,10 +56,18 @@ impl PropagationBridge { }) } + pub fn local_node(&self) -> Arc> { + self.local_node.clone() + } + pub fn local_dest_hash_hex(&self) -> String { hex::encode(self.local_dest_hash) } + pub fn local_dest_hash_bytes(&self) -> [u8; 16] { + self.local_dest_hash + } + pub fn set_local_serving(&self, enabled: bool, router: &mut LxmRouter) { self.local_serving.store(enabled, Ordering::SeqCst); router.set_propagation_enabled(enabled); @@ -351,8 +363,14 @@ mod tests { std::fs::create_dir_all(&dir).expect("tmpdir"); let (tx, _rx) = mpsc::channel(8); let identity = rns_identity::identity::Identity::new(); - let bridge = - PropagationBridge::new(tx, [0xab; 16], dir.clone(), &identity).expect("bridge"); + let bridge = PropagationBridge::new( + tx, + [0xab; 16], + dir.clone(), + &identity, + &super::super::pn_hosting_policy::PnHostingPolicy::default(), + ) + .expect("bridge"); let active = AtomicU64::new(1); let mut ran = false; assert!(bridge.run_if_current(&active, 1, || { @@ -376,8 +394,14 @@ mod tests { std::fs::create_dir_all(&dir).expect("tmpdir"); let (tx, _rx) = mpsc::channel(8); let identity = rns_identity::identity::Identity::new(); - let bridge = - PropagationBridge::new(tx, [0xab; 16], dir.clone(), &identity).expect("bridge"); + let bridge = PropagationBridge::new( + tx, + [0xab; 16], + dir.clone(), + &identity, + &super::super::pn_hosting_policy::PnHostingPolicy::default(), + ) + .expect("bridge"); bridge.cancel_sync(); assert_eq!(bridge.last_finished_ok(), Some(false)); assert!(!bridge.sync_active()); diff --git a/reticulum-sidecar/src/stack/propagation_serve.rs b/reticulum-sidecar/src/stack/propagation_serve.rs new file mode 100644 index 000000000..385b2bcca --- /dev/null +++ b/reticulum-sidecar/src/stack/propagation_serve.rs @@ -0,0 +1,135 @@ +//! Network-visible LXMF propagation-node serve path (`/offer` + `/get`). + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use lxmf_core::handlers::PropagationRequestHandler; +use lxmf_core::propagation_node::PropagationNode; +use rns_identity::destination::Destination; +use rns_identity::identity::Identity; +use rns_runtime::link_manager::{LinkManager, register_destination}; +use rns_transport::messages::TransportMessage; +use tokio::sync::mpsc; + +pub const LXMF_PROPAGATION_APP: &str = "lxmf.propagation"; + +/// Owns the inbound LinkManager task for local PN hosting. +pub struct PropagationServeHandle { + active: AtomicBool, + stop_tx: Mutex>>, +} + +impl PropagationServeHandle { + pub fn new() -> Self { + Self { + active: AtomicBool::new(false), + stop_tx: Mutex::new(None), + } + } + + pub fn stop(&self) { + self.active.store(false, Ordering::SeqCst); + if let Ok(mut slot) = self.stop_tx.lock() + && let Some(tx) = slot.take() + { + let _ = tx.send(()); + } + } + + /// Register `lxmf.propagation` and spawn LinkManager with `/offer` + `/get` handlers. + pub fn start( + &self, + transport_tx: mpsc::Sender, + identity: &Identity, + propagation_dest_hash: [u8; 16], + local_node: Arc>, + ) -> Result<(), String> { + self.stop(); + + let delivery_rx = + register_destination(&transport_tx, propagation_dest_hash, LXMF_PROPAGATION_APP); + + let prop_signing_key = identity + .get_signing_key() + .ok_or_else(|| "propagation serve: identity has no signing key".to_string())?; + + let mut prop_link_mgr = LinkManager::with_destination( + transport_tx.clone(), + delivery_rx, + identity, + LXMF_PROPAGATION_APP, + Some(prop_signing_key), + ); + + let (resource_tx, _resource_rx) = mpsc::channel::<(Vec, [u8; 16])>(256); + prop_link_mgr.set_resource_completed_channel(resource_tx); + + let pn_for_handler = local_node; + let offer_path_hash = + rns_crypto::sha::truncated_hash(lxmf_core::constants::OFFER_REQUEST_PATH.as_bytes()); + let get_path_hash = + rns_crypto::sha::truncated_hash(lxmf_core::constants::MESSAGE_GET_PATH.as_bytes()); + let link_identities = prop_link_mgr.link_identities_handle(); + let local_identity_hash = identity.hash; + prop_link_mgr.set_request_handler(move |link_id, path_hash, data| { + let remote_identity_hash = link_identities + .lock() + .ok() + .and_then(|ids| ids.get(&link_id).copied()); + let remote_identity_ref = remote_identity_hash.as_ref(); + let client_dest_hash = remote_identity_hash + .map(|identity_hash| { + Destination::hash_from_name_and_identity("lxmf.delivery", Some(&identity_hash)) + }) + .unwrap_or([0; 16]); + let handler = PropagationRequestHandler::new(local_identity_hash); + if path_hash == offer_path_hash { + tracing::info!(target: "propagation-serve", "handling /offer request"); + let mut node = pn_for_handler.lock().ok()?; + Some(handler.handle_offer_request(remote_identity_ref, &data, &mut node)) + } else if path_hash == get_path_hash { + tracing::info!(target: "propagation-serve", "handling /get request"); + let action = { + let mut node = pn_for_handler.lock().ok()?; + handler.handle_message_get_request( + remote_identity_ref, + &client_dest_hash, + &data, + &mut node, + ) + }; + Some(action.into_response()) + } else { + tracing::debug!( + target: "propagation-serve", + path = %hex::encode(path_hash), + "unknown request path" + ); + None + } + }); + + let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel(); + if let Ok(mut slot) = self.stop_tx.lock() { + *slot = Some(stop_tx); + } + self.active.store(true, Ordering::SeqCst); + + tokio::spawn(async move { + tokio::select! { + _ = prop_link_mgr.run() => {} + _ = &mut stop_rx => { + tracing::info!(target: "propagation-serve", "LinkManager stop requested"); + } + } + }); + + Ok(()) + } +} + +impl Default for PropagationServeHandle { + fn default() -> Self { + Self::new() + } +} diff --git a/src/renderer/components/ReticulumNetworkPanel.tsx b/src/renderer/components/ReticulumNetworkPanel.tsx index 3483c4f8a..1636e60a0 100644 --- a/src/renderer/components/ReticulumNetworkPanel.tsx +++ b/src/renderer/components/ReticulumNetworkPanel.tsx @@ -33,6 +33,7 @@ import { IdentityVaultPanel } from './IdentityVaultPanel'; import QrCodeImage from './QrCodeImage'; import QrIngestControl from './QrIngestControl'; import { ReticulumAnnounceControls } from './ReticulumAnnounceControls'; +import ReticulumPnHostingDangerZone from './ReticulumPnHostingDangerZone'; import ReticulumPropagationSection from './ReticulumPropagationSection'; import { ReticulumRmapDiscoveryControls } from './ReticulumRmapDiscoveryControls'; import { useToast } from './Toast'; @@ -799,6 +800,8 @@ export function ReticulumNetworkPanel({ > + + ) : null} diff --git a/src/renderer/components/ReticulumPnHostingDangerZone.test.tsx b/src/renderer/components/ReticulumPnHostingDangerZone.test.tsx new file mode 100644 index 000000000..dfe21005c --- /dev/null +++ b/src/renderer/components/ReticulumPnHostingDangerZone.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useReticulumPropagationStore } from '@/renderer/stores/reticulumPropagationStore'; +import { DEFAULT_PN_HOSTING_POLICY } from '@/shared/pnHostingPolicy'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const addToast = vi.fn(); +vi.mock('./Toast', () => ({ + useToast: () => ({ addToast }), +})); + +import ReticulumPnHostingDangerZone from './ReticulumPnHostingDangerZone'; + +describe('ReticulumPnHostingDangerZone', () => { + const original = { + hostingPolicy: useReticulumPropagationStore.getState().hostingPolicy, + refreshFromSidecar: useReticulumPropagationStore.getState().refreshFromSidecar, + setHostingPolicyOnSidecar: useReticulumPropagationStore.getState().setHostingPolicyOnSidecar, + }; + + beforeEach(() => { + addToast.mockReset(); + useReticulumPropagationStore.setState({ + hostingPolicy: { ...DEFAULT_PN_HOSTING_POLICY }, + refreshFromSidecar: vi.fn().mockResolvedValue(undefined), + setHostingPolicyOnSidecar: vi.fn().mockResolvedValue(true), + }); + }); + + afterEach(() => { + useReticulumPropagationStore.setState(original); + }); + + it('renders yellow danger zone and saves hosting policy payload', async () => { + const user = userEvent.setup(); + const setHostingPolicyOnSidecar = vi.mocked( + useReticulumPropagationStore.getState().setHostingPolicyOnSidecar, + ); + + render(); + + expect(screen.getByText('networkPanel.reticulumPnHosting.title')).toBeInTheDocument(); + + const maxPeering = screen.getByLabelText('networkPanel.reticulumPnHosting.maxPeeringCost'); + await user.clear(maxPeering); + await user.type(maxPeering, '30'); + + await user.click( + screen.getByRole('button', { name: 'networkPanel.reticulumPnHosting.saveAria' }), + ); + + await waitFor(() => { + expect(setHostingPolicyOnSidecar).toHaveBeenCalled(); + }); + const saved = setHostingPolicyOnSidecar.mock.calls[0]?.[0]; + expect(saved?.max_peering_cost).toBe(30); + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith('networkPanel.reticulumPnHosting.saveOk', 'success'); + }); + }); + + it('toasts failure when save fails', async () => { + const user = userEvent.setup(); + useReticulumPropagationStore.setState({ + setHostingPolicyOnSidecar: vi.fn().mockResolvedValue(false), + }); + + render(); + await user.click( + screen.getByRole('button', { name: 'networkPanel.reticulumPnHosting.saveAria' }), + ); + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith('networkPanel.reticulumPnHosting.saveFailed', 'error'); + }); + }); +}); diff --git a/src/renderer/components/ReticulumPnHostingDangerZone.tsx b/src/renderer/components/ReticulumPnHostingDangerZone.tsx new file mode 100644 index 000000000..b45d47c99 --- /dev/null +++ b/src/renderer/components/ReticulumPnHostingDangerZone.tsx @@ -0,0 +1,345 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useReticulumPropagationStore } from '@/renderer/stores/reticulumPropagationStore'; +import { type PnHostingPolicy } from '@/shared/pnHostingPolicy'; + +import { useToast } from './Toast'; + +interface ReticulumPnHostingDangerZoneProps { + disabled?: boolean; +} + +function NumberField({ + id, + label, + value, + min, + max, + onChange, + disabled, +}: Readonly<{ + id: string; + label: string; + value: number; + min: number; + max: number; + onChange: (n: number) => void; + disabled?: boolean; +}>) { + return ( + + ); +} + +/** + * Yellow advanced danger zone for LXMF PN hosting / peering policy. + * Collapsed by default; lives on Network after Propagation. + */ +export default function ReticulumPnHostingDangerZone({ + disabled = false, +}: Readonly) { + const { t } = useTranslation(); + const { addToast } = useToast(); + const hostingPolicy = useReticulumPropagationStore((s) => s.hostingPolicy); + const setHostingPolicyOnSidecar = useReticulumPropagationStore( + (s) => s.setHostingPolicyOnSidecar, + ); + const refreshFromSidecar = useReticulumPropagationStore((s) => s.refreshFromSidecar); + + const [draft, setDraft] = useState(hostingPolicy); + const [policySnapshot, setPolicySnapshot] = useState(hostingPolicy); + const [saving, setSaving] = useState(false); + + if (hostingPolicy !== policySnapshot) { + setPolicySnapshot(hostingPolicy); + setDraft(hostingPolicy); + } + + useEffect(() => { + void refreshFromSidecar(); + }, [refreshFromSidecar]); + + const patch = (key: K, value: PnHostingPolicy[K]) => { + setDraft((prev) => ({ ...prev, [key]: value })); + }; + + return ( +
+ + {t('networkPanel.reticulumPnHosting.title')} + +

+ {t('networkPanel.reticulumPnHosting.warning')} +

+
+ { + patch('peering_cost', n); + }} + /> + { + patch('max_peering_cost', n); + }} + /> + { + patch('autopeer_maxdepth', n); + }} + /> + { + patch('max_peers', n); + }} + /> + { + patch('propagation_stamp_cost', n); + }} + /> + { + patch('propagation_stamp_flex', n); + }} + /> + { + patch('message_storage_limit_mb', n); + }} + /> + { + patch('propagation_limit_kb', n); + }} + /> + { + patch('sync_limit_kb', n); + }} + /> + { + patch('delivery_limit_kb', n); + }} + /> + { + patch('pn_announce_interval_sec', n); + }} + /> +
+
+ + + + + + +
+ +