From 8f179dec533c92c20994d8ead251d2202fb496f5 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sat, 15 Aug 2026 07:37:11 -0600 Subject: [PATCH 1/9] fix(meshcore): send empty room guest passwords instead of hello Stop substituting blank guest login with the older "hello" default so Login matches MeshCore guidance (try empty first). --- README.md | 2 +- docs/agents/meshcore-rooms.md | 2 +- docs/meshcore-meshtastic-parity.md | 2 +- docs/troubleshooting.md | 10 +++--- src/renderer/components/RoomsPanel.test.tsx | 35 ++++++++++++++++--- src/renderer/components/RoomsPanel.tsx | 24 +++++++------ .../hooks/useMeshcoreRoomAuth.test.tsx | 14 ++++++++ .../lib/meshcoreInfraAdminSecrets.test.ts | 9 +++++ src/renderer/lib/meshcoreInfraAdminSecrets.ts | 9 ++--- .../lib/meshcoreRoomCredentialStorage.test.ts | 20 +++++++++-- .../lib/meshcoreRoomCredentialStorage.ts | 34 ++++++++++++------ src/renderer/lib/meshcoreRoomSession.test.ts | 23 ++++++++++++ src/renderer/lib/meshcoreRoomSession.ts | 10 ++++-- src/renderer/locales/en/translation.json | 12 +++---- 14 files changed, 157 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 63ea6b2cc..83706659d 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ MeshCore runs simultaneously alongside Meshtastic and Reticulum. Use the protoco **Room servers (BBS)** — **Rooms** tab (RF only; not MQTT) -- Login to room-server contacts (guest read-only or admin post); **Continue read-only** when the server guest password is empty (zero-byte password, matching the official Android client) +- Login to room-server contacts (guest read-only or admin post); try an **empty guest password** first (zero-byte SendLogin, matching official clients); **"hello"** is the older default if empty fails; **Continue read-only** also sends no password - Post plain UTF-8 after login; inbound **SignedPlain** pushes show author prefix stripped in the UI - **Remember password**, **Auto-sync** (periodic re-login while connected, minimum 60 minutes per room), per-room unread badges (sidebar **Rooms** tab; separate from **Chat** badges) - Room admin CLI / ACL setperm on the **Repeaters** tab (room rows); Rooms Members still call `get acl` via the same CLI path. Session/login queue and path sync in `meshcoreRoom*.ts` — see [docs/meshcore-meshtastic-parity.md](docs/meshcore-meshtastic-parity.md#meshcore-room-servers) and [Troubleshooting](docs/troubleshooting.md#meshcore-room-server-login-posts-and-windows-10) diff --git a/docs/agents/meshcore-rooms.md b/docs/agents/meshcore-rooms.md index 3520139e7..a486552e2 100644 --- a/docs/agents/meshcore-rooms.md +++ b/docs/agents/meshcore-rooms.md @@ -2,7 +2,7 @@ Deep subsystem reference for AI assistants. Open this when a task touches MeshCore Rooms login/post, session RPCs, saved passwords, auto-sync scheduling, or room wire text. Hard rules live in [`AGENTS.md`](../../AGENTS.md). -- **UI:** `RoomsPanel.tsx` — login overlay, post composer (`ChatComposer`), auto-sync toggles, Members ACL refresh (`get acl`); **Manage on Repeaters** jumps to Repeaters & Rooms ops (CLI / setperm / infra). Sidebar badge via `meshcoreRoomsUnread.ts` (`mesh-client:meshcoreRoomsUnread`). +- **UI:** `RoomsPanel.tsx` — login overlay, post composer (`ChatComposer`), auto-sync toggles, Members ACL refresh (`get acl`); **Manage on Repeaters** jumps to Repeaters & Rooms ops (CLI / setperm / infra). Sidebar badge via `meshcoreRoomsUnread.ts` (`mesh-client:meshcoreRoomsUnread`). Empty guest **Login** sends zero password bytes (try empty first; `"hello"` is the older default); **Continue read-only** sends the same empty password. - **Ops (not on Rooms tab):** Ping / Status / Neighbors / Sensor / CLI / ACL setperm live on `RepeatersPanel` (mixed Repeater + Room table). **Open room** on a room row jumps back to Rooms. Guest passwords stay in `meshcoreRoomCredentialStorage`; ops admin uses `meshcoreInfraAdminSecrets` (admin-only records allowed). - **Room remote CLI:** needs room **admin** password via SendLogin ACL (`meshcoreCancelRoomLogin` then admin login inside `sendRepeaterCliCommand`); guest BBS login is not enough (`repeatersPanel.roomCliNeedsAdminPassword`). - **Post length (single-packet):** room posts use the shared `ChatComposer` (`variant="room"`) and are **single-packet** like MeshCore chat — over the room payload limit (`getMeshcoreRoomPayloadLimit` in `chatComposerLimits.ts`) the send is blocked with the same `meshcoreSingleNotice` callout rather than split into `[i/N]` parts, and the ~5s fast-send advisory applies. See [`chat.md`](chat.md) (Composer limits / send cadence). diff --git a/docs/meshcore-meshtastic-parity.md b/docs/meshcore-meshtastic-parity.md index 2443bdbf7..fbb3528bf 100644 --- a/docs/meshcore-meshtastic-parity.md +++ b/docs/meshcore-meshtastic-parity.md @@ -52,7 +52,7 @@ Shared UI gates use `ProtocolCapabilities` in [`src/renderer/lib/radio/BaseRadio Room servers (`hw_model === 'Room'`, contact type 3) are BBS nodes on the mesh. The companion radio must be connected over **RF** (BLE, serial, or TCP); **MQTT does not carry room login/post**. -**Login:** Guest read-only uses **zero password bytes** when the server guest password is empty (**Continue read-only** on the login overlay). Admin login uses the configured password. Login RPC, queue, and path sync live under `src/renderer/lib/meshcoreRoom*.ts` (e.g. [`meshcoreRoomLoginRpc.ts`](../src/renderer/lib/meshcoreRoomLoginRpc.ts), [`meshcoreRoomLoginQueue.ts`](../src/renderer/lib/meshcoreRoomLoginQueue.ts)); timeouts are shorter on TCP and 0-hop paths ([`timeConstants.ts`](../src/renderer/lib/timeConstants.ts)). +**Login:** Guest Login with an empty password sends **zero password bytes** (same wire as **Continue read-only**). Prefer empty first; **`hello`** is the older factory default. Admin login uses the configured password. Login RPC, queue, and path sync live under `src/renderer/lib/meshcoreRoom*.ts` (e.g. [`meshcoreRoomLoginRpc.ts`](../src/renderer/lib/meshcoreRoomLoginRpc.ts), [`meshcoreRoomLoginQueue.ts`](../src/renderer/lib/meshcoreRoomLoginQueue.ts)); timeouts are shorter on TCP and 0-hop paths ([`timeConstants.ts`](../src/renderer/lib/timeConstants.ts)). **Posts:** Outbound room posts use plain UTF-8 (`TXT_TYPE_PLAIN`) after login and are **single-packet** — mesh-client does not emit multi-part `[i/N]` room posts; over-limit text is blocked in the composer (same rationale as chat/DM). Inbound **SignedPlain** pushes include a four-byte author prefix; the **Rooms** UI strips it, and inbound multi-part from other clients is still merged for display. Posts appear in the **Rooms** tab (channel `-2`), not Chat channel pills. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3e3cd1af1..1a83a434f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -892,9 +892,9 @@ The client deduplicates overlapping RF and MQTT hears within **5 minutes** (cros **Guest / read-only login fails with timeout or "rejected"**: -- When the room server **guest password is empty**, use **Continue read-only** on the Rooms login overlay. That sends **zero password bytes** (same as the official Android app). **Login** with an empty guest field is disabled; it would send the default **`hello`** password instead. -- When the server **does** configure a guest password, enter that value in the guest field and click **Login** (some communities use **`hello`**). -- **Room admin CLI** (**Repeaters** tab → room row CLI; needs the room **admin** password via SendLogin ACL, not guest BBS login): many stock room servers use **`hello`** as the default admin password when none was configured. Save the admin password under Repeaters → password for that room. Do not confuse that with **Continue read-only** on the Rooms login overlay, which sends zero bytes for blank guest servers. +- Try **Login** with an **empty** guest field first. That sends **zero password bytes** (same as the official Android app and **Continue read-only**). Most rooms leave the guest password blank for guests. +- If empty login fails, try the older default guest password **`hello`**. +- **Room admin CLI** (**Repeaters** tab → room row CLI; needs the room **admin** password via SendLogin ACL, not guest BBS login): many stock room servers use **`hello`** as the default admin password when none was configured. Save the admin password under Repeaters → password for that room. Do not confuse that with blank guest Login / **Continue read-only**, which send zero bytes. - Logs showing push **`0x86`** (frame 134) mean **LoginFail** (wrong password or ACL denied). **Room login** rejects immediately on a prefix-matched LoginFail. **Repeater admin login** keeps waiting for a possible LoginSuccess (meshcore.js behavior on congested links); timeout after LoginFail alone is reported as timeout, not wrong password. - **Admin password** working while guest/read-only fails usually means the guest password on the server does not match what the client sent, or ACL denies read-only login. - If the room **changed its password** and mesh-client keeps trying to log in, open the **Rooms** tab: expand **Saved passwords** in the sidebar (or use the login overlay for the selected room). Use **Stop auto-login** to stop connect-time retries while keeping the old password stored, or **Forget saved password** to clear the stored guest/admin password and turn off auto-login and auto-sync. After a wrong-password failure, auto-login is turned off automatically until you log in again with **Remember password** or re-enable it. @@ -935,7 +935,7 @@ The client deduplicates overlapping RF and MQTT hears within **5 minutes** (cros **Read-only → write upgrade does nothing**: -- After **Continue read-only**, use **Upgrade access** (or **Login** with the guest password) so the client sends a fresh **SendLogin** with `forceRelogin`. Enter the real guest password (often **`hello`**); empty field Login is disabled on the main overlay to avoid sending `hello` when the server expects blank read-only login. +- After **Continue read-only**, use **Upgrade access** (or **Login** with the guest password) so the client sends a fresh **SendLogin** with `forceRelogin`. If the room still uses a guest password, enter it (often **`hello`** on older servers); an empty field cannot upgrade write access. **Long room posts show as `[1/2]`, `[2/2]`…**: @@ -954,7 +954,7 @@ The client deduplicates overlapping RF and MQTT hears within **5 minutes** (cros **Retest checklist (after upgrading from a known-good build)**: 1. Connect MeshCore over TCP or BLE; confirm nodes load. -2. Open **Rooms** → with **empty guest password** on the server, click **Continue read-only** (not **Login** with an empty field). With a configured guest password, enter it and click **Login**. +2. Open **Rooms** → try **Login** with an **empty** guest field first (or **Continue read-only**). If that fails, try **`hello`**. With a configured guest password, enter it and click **Login**. 3. Post as admin; confirm the post appears in the **official Android app** on the same room (SignedPlain BBS path). 4. Confirm room posts appear in **Rooms** with unread badges (not Chat channel pills). 5. On **Connection** tab, receive a **channel** message on a channel you are not viewing → sidebar **Chat** badge and red pill on that channel when you open Chat. diff --git a/src/renderer/components/RoomsPanel.test.tsx b/src/renderer/components/RoomsPanel.test.tsx index f329c48fb..73482ed9b 100644 --- a/src/renderer/components/RoomsPanel.test.tsx +++ b/src/renderer/components/RoomsPanel.test.tsx @@ -167,6 +167,9 @@ describe('RoomsPanel', () => { const onLoginRoom = vi.fn().mockResolvedValue(undefined); renderRoomsPanel(nodes, { initialRoomTarget: room.node_id, onLoginRoom }); + fireEvent.change(screen.getByLabelText('roomsPanel.guestPasswordLabel'), { + target: { value: 'hello' }, + }); fireEvent.click(screen.getByRole('button', { name: 'roomsPanel.upgradeAccess' })); await waitFor(() => { @@ -275,14 +278,36 @@ describe('RoomsPanel', () => { }); }); - it('disables Login when guest password field is empty', () => { + it('allows Login with empty guest password and sends blank', async () => { + meshcoreClearAllRoomSessions(); const room = makeRoom(0x1004, 'Empty Guest Room'); const nodes = new Map([[room.node_id, room]]); - renderRoomsPanel(nodes, { initialRoomTarget: room.node_id }); - fireEvent.change(screen.getByLabelText('roomsPanel.guestPasswordLabel'), { - target: { value: '' }, + const onLoginRoom = vi.fn().mockResolvedValue(undefined); + renderRoomsPanel(nodes, { initialRoomTarget: room.node_id, onLoginRoom }); + expect(screen.getByLabelText('roomsPanel.guestPasswordLabel')).toHaveValue(''); + expect(screen.getByText('roomsPanel.emptyGuestLoginHint')).toBeInTheDocument(); + expect(screen.getByText('roomsPanel.loginButton')).not.toBeDisabled(); + fireEvent.click(screen.getByText('roomsPanel.loginButton')); + await waitFor(() => { + expect(onLoginRoom).toHaveBeenCalledWith( + room.node_id, + '', + expect.objectContaining({ guestPassword: '' }), + ); }); - expect(screen.getByText('roomsPanel.loginButton')).toBeDisabled(); + }); + + it('disables Upgrade access when guest password field is empty', () => { + meshcoreClearAllRoomSessions(); + const room = makeRoom(0x100d, 'Upgrade Empty Room'); + const nodes = new Map([[room.node_id, room]]); + meshcoreApplyRoomSession(room.node_id, { + guestPassword: '', + adminPassword: '', + role: 'readonly', + }); + renderRoomsPanel(nodes, { initialRoomTarget: room.node_id }); + expect(screen.getByRole('button', { name: 'roomsPanel.upgradeAccess' })).toBeDisabled(); expect(screen.getByText('roomsPanel.emptyGuestLoginHint')).toBeInTheDocument(); }); diff --git a/src/renderer/components/RoomsPanel.tsx b/src/renderer/components/RoomsPanel.tsx index aa76daaf8..ec8b2c85e 100644 --- a/src/renderer/components/RoomsPanel.tsx +++ b/src/renderer/components/RoomsPanel.tsx @@ -71,7 +71,6 @@ import { getMeshcoreRoomSavedSecretsSummary, } from '@/renderer/lib/meshcoreRoomSavedSecrets'; import { - MESHCORE_ROOM_DEFAULT_GUEST_PASSWORD, meshcoreCancelAllRoomLogins, meshcoreGetRoomSession, meshcoreIsRoomLoggedIn, @@ -264,7 +263,7 @@ export default function RoomsPanel({ const [selectedRoomId, setSelectedRoomId] = useState( () => initialRoomTarget ?? null, ); - const [loginPassword, setLoginPassword] = useState(MESHCORE_ROOM_DEFAULT_GUEST_PASSWORD); + const [loginPassword, setLoginPassword] = useState(''); /** Tracks in-flight login promises before the shared queue snapshot updates (tests / fast paths). */ const [localLoginRoomIds, setLocalLoginRoomIds] = useState>(() => new Set()); const [leaveLoadingRoomIds, setLeaveLoadingRoomIds] = useState>(() => new Set()); @@ -812,7 +811,7 @@ export default function RoomsPanel({ next.delete(nodeId); return next; }); - setLoginPassword(MESHCORE_ROOM_DEFAULT_GUEST_PASSWORD); + setLoginPassword(''); setRememberPassword(false); loadSyncConfig(nodeId); }, @@ -946,7 +945,7 @@ export default function RoomsPanel({ forceRelogin, }); if (rememberPassword) refreshStoredRooms(); - setLoginPassword(MESHCORE_ROOM_DEFAULT_GUEST_PASSWORD); + setLoginPassword(''); }); }, [ loginPassword, @@ -1276,8 +1275,13 @@ export default function RoomsPanel({ : savedRoomsNotLoggedInCount === 0 ? t('roomsPanel.loginAllSavedDisabledAllLoggedIn') : ''; - const loginButtonEnabled = isConnected && !guestFieldEmpty && !selectedRoomLoginLoading; - const loginButtonClass = loginButtonEnabled + /** Overlay Login may send a zero-byte password; upgrade needs a non-empty guest password. */ + const overlayLoginEnabled = isConnected && !selectedRoomLoginLoading; + const upgradeLoginEnabled = overlayLoginEnabled && !guestFieldEmpty; + const overlayLoginButtonClass = overlayLoginEnabled + ? 'border-readable-green bg-readable-green w-full cursor-pointer rounded border px-3 py-2 text-sm font-semibold text-white hover:bg-readable-green/90' + : 'w-full cursor-not-allowed rounded border border-gray-600 bg-gray-700 px-3 py-2 text-sm font-medium text-gray-500'; + const upgradeLoginButtonClass = upgradeLoginEnabled ? 'border-readable-green bg-readable-green w-full cursor-pointer rounded border px-3 py-2 text-sm font-semibold text-white hover:bg-readable-green/90' : 'w-full cursor-not-allowed rounded border border-gray-600 bg-gray-700 px-3 py-2 text-sm font-medium text-gray-500'; const selectedRoomLeaveLoading = @@ -1738,8 +1742,8 @@ export default function RoomsPanel({ @@ -2657,8 +2661,8 @@ export default function RoomsPanel({ {loginError &&

{loginError}

} - {getMeshcoreRoomAutoLoginFailure(selectedRoomId) && !loginError && ( + {autoLoginFailureDisplay && !loginError && (

{t('roomsPanel.autoLoginFailed', { - error: getMeshcoreRoomAutoLoginFailure(selectedRoomId), + error: autoLoginFailureDisplay, })}

)} diff --git a/src/renderer/lib/meshcoreRoomLoginPathSync.test.ts b/src/renderer/lib/meshcoreRoomLoginPathSync.test.ts index 889d5df11..93931cb71 100644 --- a/src/renderer/lib/meshcoreRoomLoginPathSync.test.ts +++ b/src/renderer/lib/meshcoreRoomLoginPathSync.test.ts @@ -103,3 +103,46 @@ describe('syncMeshcoreRoomContactPathBeforeLogin', () => { expect(addOrUpdateContact).toHaveBeenCalled(); }); }); + +describe('resetMeshcoreRoomCompanionSyncSinceForCatchUp', () => { + it('skips when remove/add APIs are missing', async () => { + const pubKey = makePubKey(2); + const conn = { getContacts: vi.fn(), setContactPath: vi.fn() }; + const { resetMeshcoreRoomCompanionSyncSinceForCatchUp } = + await import('./meshcoreRoomLoginPathSync'); + await expect( + resetMeshcoreRoomCompanionSyncSinceForCatchUp(conn, pubkeyToNodeId(pubKey), pubKey), + ).resolves.toBe('skipped'); + }); + + it('remove+readds room contact to zero companion sync_since', async () => { + const pubKey = makePubKey(0x40); + const nodeId = pubkeyToNodeId(pubKey); + const contact: MeshCoreContactRaw = { + publicKey: pubKey, + type: 3, + flags: 0, + outPathLen: 0, + outPath: new Uint8Array(64), + advName: 'CatchUp Room', + lastAdvert: 1, + advLat: 0, + advLon: 0, + }; + const removeContact = vi.fn().mockResolvedValue(undefined); + const addOrUpdateContact = vi.fn().mockResolvedValue(undefined); + const conn = { + getContacts: vi.fn().mockResolvedValue([contact]), + setContactPath: vi.fn(), + removeContact, + addOrUpdateContact, + }; + const { resetMeshcoreRoomCompanionSyncSinceForCatchUp } = + await import('./meshcoreRoomLoginPathSync'); + await expect(resetMeshcoreRoomCompanionSyncSinceForCatchUp(conn, nodeId, pubKey)).resolves.toBe( + 'reset', + ); + expect(removeContact).toHaveBeenCalledWith(pubKey); + expect(addOrUpdateContact).toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/lib/meshcoreRoomLoginPathSync.ts b/src/renderer/lib/meshcoreRoomLoginPathSync.ts index 67e35279a..d01e1fad1 100644 --- a/src/renderer/lib/meshcoreRoomLoginPathSync.ts +++ b/src/renderer/lib/meshcoreRoomLoginPathSync.ts @@ -26,6 +26,62 @@ export interface MeshcoreRoomLoginPathSyncConn { advLat: number, advLon: number, ): Promise; + removeContact?(pubKey: Uint8Array): Promise; +} + +/** + * Force companion `ContactInfo.sync_since = 0` so room login requests the ring-buffer catch-up. + * Firmware only zeroes sync_since on *new* contacts; updates preserve the watermark. + * Failure point: remove/re-add fails — caller continues login (may still get live posts). + */ +export async function resetMeshcoreRoomCompanionSyncSinceForCatchUp( + conn: MeshcoreRoomLoginPathSyncConn, + nodeId: number, + pubKey: Uint8Array, +): Promise<'reset' | 'skipped' | 'failed'> { + if (!conn.removeContact || !conn.addOrUpdateContact) return 'skipped'; + let contact: MeshCoreContactRaw | undefined; + try { + const contacts = await withTimeout( + conn.getContacts(), + MESHCORE_ROOM_LOGIN_PATH_SYNC_TIMEOUT_MS, + 'meshcoreRoomSyncSinceResetGetContacts', + ); + contact = findRadioContact(contacts, nodeId); + } catch { + // catch-no-log-ok getContacts timeout/failure — caller treats 'failed' and continues login + return 'failed'; + } + if (!contact) return 'skipped'; + try { + await withTimeout( + conn.removeContact(pubKey), + MESHCORE_ROOM_LOGIN_PATH_SYNC_TIMEOUT_MS, + 'meshcoreRoomSyncSinceResetRemove', + ); + await withTimeout( + conn.addOrUpdateContact( + pubKey, + contact.type, + contact.flags, + contact.outPathLen ?? 0, + contact.outPath instanceof Uint8Array ? contact.outPath : new Uint8Array(64), + contact.advName, + contact.lastAdvert, + contact.advLat, + contact.advLon, + ), + MESHCORE_ROOM_LOGIN_PATH_SYNC_TIMEOUT_MS, + 'meshcoreRoomSyncSinceResetAdd', + ); + return 'reset'; + } catch (e: unknown) { + console.warn( + '[meshcoreRoomLoginPathSync] sync_since catch-up reset failed ' + + (e instanceof Error ? e.message : String(e)), + ); + return 'failed'; + } } function packContactOutPath(path: Uint8Array): Uint8Array { diff --git a/src/renderer/lib/meshcoreRoomLoginQueue.test.ts b/src/renderer/lib/meshcoreRoomLoginQueue.test.ts index 8b65fef7a..5c52600a8 100644 --- a/src/renderer/lib/meshcoreRoomLoginQueue.test.ts +++ b/src/renderer/lib/meshcoreRoomLoginQueue.test.ts @@ -78,4 +78,26 @@ describe('meshcoreRoomLoginQueue', () => { name: 'AbortError', }); }); + + it('new enqueue after cancel of active login is not sticky-skipped', async () => { + let releaseFirst: (() => void) | undefined; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const first = enqueueMeshcoreRoomLogin(42, async () => { + await firstGate; + }); + await Promise.resolve(); + expect(getMeshcoreRoomLoginQueueSnapshot().activeNodeId).toBe(42); + + // Cancel while active: abort path marks skipped even though the job already started. + dequeueMeshcoreRoomLogin(42); + releaseFirst?.(); + await expect(first).resolves.toBeUndefined(); + + const ran = vi.fn(() => Promise.resolve()); + const third = enqueueMeshcoreRoomLogin(42, ran); + await expect(third).resolves.toBeUndefined(); + expect(ran).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/renderer/lib/meshcoreRoomLoginQueue.ts b/src/renderer/lib/meshcoreRoomLoginQueue.ts index e89fd6594..59c7ce131 100644 --- a/src/renderer/lib/meshcoreRoomLoginQueue.ts +++ b/src/renderer/lib/meshcoreRoomLoginQueue.ts @@ -53,12 +53,16 @@ export function meshcoreRoomLoginQueueSize(): number { * Failure point: prior jobs block the queue — callers should not assume immediate start. */ export function enqueueMeshcoreRoomLogin(nodeId: number, run: () => Promise): Promise { + // A new enqueue means the user wants another attempt. Clear a leftover skip from + // canceling an *active* login (dequeue marks skipped even when the job already started). + skippedNodeIds.delete(nodeId); pendingNodeIds.add(nodeId); notifyQueueChanged(); const job = chain.then(async () => { pendingNodeIds.delete(nodeId); - if (skippedNodeIds.has(nodeId)) { + const wasSkipped = skippedNodeIds.has(nodeId); + if (wasSkipped) { skippedNodeIds.delete(nodeId); throw new DOMException(MESHCORE_ROOM_LOGIN_ABORT_MESSAGE, 'AbortError'); } diff --git a/src/renderer/lib/meshcoreRoomLoginRpc.ts b/src/renderer/lib/meshcoreRoomLoginRpc.ts index fff6cb109..1d77a58db 100644 --- a/src/renderer/lib/meshcoreRoomLoginRpc.ts +++ b/src/renderer/lib/meshcoreRoomLoginRpc.ts @@ -126,7 +126,7 @@ export function runMeshcoreRoomLogin( return; } console.debug( - `[meshcoreRoomLoginRpc] LoginSuccess prefix=${prefixToHex(prefix)} permissions=${String(r.permissions ?? r.reserved ?? 'n/a')}`, + `[meshcoreRoomLoginRpc] LoginSuccess prefix=${prefixToHex(prefix)} reserved=${String(r.reserved ?? 'n/a')} permissions=${String(r.permissions ?? 'n/a')}`, ); succeed(r); }; diff --git a/src/renderer/lib/meshcoreRoomSession.test.ts b/src/renderer/lib/meshcoreRoomSession.test.ts index 9ce28811b..2345d4780 100644 --- a/src/renderer/lib/meshcoreRoomSession.test.ts +++ b/src/renderer/lib/meshcoreRoomSession.test.ts @@ -108,9 +108,27 @@ describe('meshcoreRoomSession', () => { expect(meshcoreRoomCanPost(42)).toBe(true); }); - it('login uses meshcore.js reserved ACL byte over empty-password hint', async () => { + it('login maps legacy reserved=0 (non-guest) to read-write when ACL byte absent', async () => { + meshcoreClearAllRoomSessions(); + // Companion data[6]: 0 = not guest (read-write or read-only ACL); without permissions, treat as RW. + mockRunMeshcoreRoomLogin.mockResolvedValue({ reserved: 0 }); + const conn = { + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + sendToRadioFrame: vi.fn(), + }; + const pubKey = new Uint8Array(32); + await meshcoreRoomLogin(conn, 42, pubKey, 'hello', { + guestPassword: 'hello', + adminPassword: '', + }); + expect(meshcoreGetRoomSession(42)?.role).toBe('readwrite'); + expect(meshcoreRoomCanPost(42)).toBe(true); + }); + + it('login maps legacy reserved=2 (guest hint) to read-only when ACL byte absent', async () => { meshcoreClearAllRoomSessions(); - // Empty password hint would be readonly; reserved=2 (read-write) must win. mockRunMeshcoreRoomLogin.mockResolvedValue({ reserved: 2 }); const conn = { on: vi.fn(), @@ -123,13 +141,14 @@ describe('meshcoreRoomSession', () => { guestPassword: '', adminPassword: '', }); - expect(meshcoreGetRoomSession(42)?.role).toBe('readwrite'); - expect(meshcoreRoomCanPost(42)).toBe(true); + expect(meshcoreGetRoomSession(42)?.role).toBe('readonly'); + expect(meshcoreRoomCanPost(42)).toBe(false); }); - it('login treats reserved guest ACL as read-only', async () => { + it('login prefers permissions ACL over legacy reserved flag', async () => { meshcoreClearAllRoomSessions(); - mockRunMeshcoreRoomLogin.mockResolvedValue({ reserved: 0 }); + // reserved=0 looks like RW legacy; permissions=0 is true guest ACL. + mockRunMeshcoreRoomLogin.mockResolvedValue({ permissions: 0, reserved: 0 }); const conn = { on: vi.fn(), off: vi.fn(), @@ -162,6 +181,24 @@ describe('meshcoreRoomSession', () => { expect(meshcoreGetRoomSession(42)?.role).toBe('admin'); }); + it('login with permissions=2 grants read-write (hello guest password)', async () => { + meshcoreClearAllRoomSessions(); + mockRunMeshcoreRoomLogin.mockResolvedValue({ permissions: 2, reserved: 0 }); + const conn = { + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + sendToRadioFrame: vi.fn(), + }; + const pubKey = new Uint8Array(32); + await meshcoreRoomLogin(conn, 42, pubKey, 'hello', { + guestPassword: 'hello', + adminPassword: '', + }); + expect(meshcoreGetRoomSession(42)?.role).toBe('readwrite'); + expect(meshcoreRoomCanPost(42)).toBe(true); + }); + it('skips relogin when already logged in without forceRelogin', async () => { meshcoreClearAllRoomSessions(); mockRunMeshcoreRoomLogin.mockResolvedValue({ permissions: 2 }); diff --git a/src/renderer/lib/meshcoreRoomSession.ts b/src/renderer/lib/meshcoreRoomSession.ts index 234c238e6..ae1f40dc3 100644 --- a/src/renderer/lib/meshcoreRoomSession.ts +++ b/src/renderer/lib/meshcoreRoomSession.ts @@ -212,9 +212,20 @@ function roleFromPermissionsByte(permissions: number): MeshcoreRoomRole { if (roleBits === MESHCORE_ROOM_PERM_ADMIN) return 'admin'; if (roleBits === MESHCORE_ROOM_PERM_READ_WRITE) return 'readwrite'; if (roleBits === MESHCORE_ROOM_PERM_GUEST) return 'readonly'; + // PERM_ACL_READ_ONLY (1) and unknown → read-only UI. return 'readonly'; } +/** + * Companion `LoginSuccess.reserved` is the legacy data[6] flag from room/repeater login OK — + * NOT PERM_ACL_*. Firmware: admin→1, guest(perm==0)→2, otherwise→0 (includes read-write). + */ +function roleFromLegacyLoginFlag(legacyFlag: number): MeshcoreRoomRole { + if (legacyFlag === 1) return 'admin'; + if (legacyFlag === 2) return 'readonly'; + return 'readwrite'; +} + function roleFromPasswordHint( password: string, adminPassword: string, @@ -227,19 +238,38 @@ function roleFromPasswordHint( return 'readwrite'; } -/** Prefer `permissions`; meshcore.js LoginSuccess puts the ACL byte in `reserved`. */ -function parseLoginResponsePermissions(response: unknown): number | null { +/** + * Prefer v7+ `permissions` (PERM_ACL_*). Do not treat `reserved` as ACL — that byte is the + * legacy admin/guest hint (0=RW, 1=admin, 2=guest) and inverts read-write vs guest. + */ +export function parseLoginResponsePermissions(response: unknown): number | null { if (!response || typeof response !== 'object') return null; const r = response as Record; if (typeof r.permissions === 'number' && Number.isFinite(r.permissions)) { return r.permissions & 0xff; } - if (typeof r.reserved === 'number' && Number.isFinite(r.reserved)) { - return r.reserved & 0xff; - } return null; } +export function resolveMeshcoreRoomLoginRole( + response: unknown, + password: string, + adminPassword: string, + guestPassword: string, +): MeshcoreRoomRole { + const permByte = parseLoginResponsePermissions(response); + if (permByte != null) { + return roleFromPermissionsByte(permByte); + } + if (response && typeof response === 'object') { + const reserved = (response as Record).reserved; + if (typeof reserved === 'number' && Number.isFinite(reserved)) { + return roleFromLegacyLoginFlag(reserved & 0xff); + } + } + return roleFromPasswordHint(password, adminPassword, guestPassword); +} + export function meshcoreApplyRoomSession( nodeId: number, params: { @@ -359,11 +389,12 @@ export async function meshcoreRoomLogin( signal, }); throwIfRoomLoginAborted(signal); - const permByte = parseLoginResponsePermissions(response); - const role = - permByte != null - ? roleFromPermissionsByte(permByte) - : roleFromPasswordHint(password, adminPassword, guestPassword); + const role = resolveMeshcoreRoomLoginRole( + response, + password, + adminPassword, + guestPassword, + ); const lastPostMs = getMeshcoreRoomLastPostAt(nodeId); meshcoreApplyRoomSession(nodeId, { guestPassword, diff --git a/src/renderer/lib/meshcoreUtils.test.ts b/src/renderer/lib/meshcoreUtils.test.ts index 2d8942603..057f9aa0d 100644 --- a/src/renderer/lib/meshcoreUtils.test.ts +++ b/src/renderer/lib/meshcoreUtils.test.ts @@ -498,7 +498,7 @@ describe('meshcoreInferHopsFromOutPath', () => { }); describe('resolveMeshcoreRoomLoginHopsAway', () => { - it('prefers positive hops_away on the node', () => { + it('prefers positive hops_away when a multi-hop path exists', () => { expect(resolveMeshcoreRoomLoginHopsAway({ hops_away: 4 }, new Uint8Array([1, 2]))).toBe(4); }); @@ -510,6 +510,11 @@ describe('resolveMeshcoreRoomLoginHopsAway', () => { it('returns 0 for direct room with no path', () => { expect(resolveMeshcoreRoomLoginHopsAway({ hops_away: 0 }, undefined)).toBe(0); }); + + it('ignores sticky UI hops when outPath is empty (0-hop SendLogin)', () => { + expect(resolveMeshcoreRoomLoginHopsAway({ hops_away: 1 }, undefined)).toBe(0); + expect(resolveMeshcoreRoomLoginHopsAway({ hops_away: 3 }, new Uint8Array())).toBe(0); + }); }); describe('meshcoreMergeContactHopsAwayFromPrevious', () => { diff --git a/src/renderer/lib/meshcoreUtils.ts b/src/renderer/lib/meshcoreUtils.ts index 2c4b08d17..19f3b2814 100644 --- a/src/renderer/lib/meshcoreUtils.ts +++ b/src/renderer/lib/meshcoreUtils.ts @@ -562,15 +562,18 @@ export function meshcoreInferHopsFromOutPath(contact: { } /** - * Hop count for room login timeout scaling when UI `hops_away` is 0/unknown but a route exists. - * Failure point: firmware reports direct (0 hops) while `outPath` still holds multi-hop bytes. + * Hop count for room login path/timeout decisions. + * Prefer route bytes when UI reports 0 but `outPath` still holds multi-hop path. + * Do not trust sticky UI `hops_away > 0` alone with an empty path — that blocks 0-hop + * SendLogin (noRoute) for rooms that are actually direct (contact merge keeps old hops). */ export function resolveMeshcoreRoomLoginHopsAway( node: Pick | undefined, outPathBytes?: Uint8Array, ): number { const hops = node?.hops_away; - if (typeof hops === 'number' && Number.isFinite(hops) && hops > 0) { + const hasMultiHopPath = Boolean(outPathBytes && outPathBytes.length > 1); + if (typeof hops === 'number' && Number.isFinite(hops) && hops > 0 && hasMultiHopPath) { return Math.trunc(hops); } if (outPathBytes && outPathBytes.length > 0) { @@ -582,6 +585,10 @@ export function resolveMeshcoreRoomLoginHopsAway( return Math.max(1, outPathBytes.length - 1); } } + // Sticky multi-hop with no route bytes → treat as direct for login. + if (typeof hops === 'number' && Number.isFinite(hops) && hops >= 0 && !hasMultiHopPath) { + return 0; + } if (typeof hops === 'number' && Number.isFinite(hops) && hops >= 0) { return Math.trunc(hops); } diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index eeeb0d05c..4f7d8738a 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -4539,7 +4539,7 @@ "legendNotSavedTooltip": "Prázdný kruh — pro tuto místnost není uloženo žádné heslo", "legendSavedTooltip": "Modrý polokruh — heslo uloženo; přihlaste se pro otevření relace", "legendLoggedInTooltip": "Zelená tečka — aktivní relace klienta (opuste místnost, pokud je server offline)", - "historyLocalHint": "Příspěvky se zobrazují pouze, když jste na tomto zařízení přihlášeni do této místnosti. Povolte automatickou synchronizaci, abyste zůstali přihlášeni k odběru; historie není zaplněna z pokojového serveru.", + "historyLocalHint": "Při přihlášení mesh-client vyžádá nedávné příspěvky stále v kruhové vyrovnávací paměti serveru místnosti (pokud toto zařízení ještě nemá místní vodoznak). Povolte automatickou synchronizaci, abyste zůstali přihlášeni k odběru při připojení.", "loginAllSavedDisabledNoSavedPasswords": "Pro použití všech přihlašovacích údajů si nejprve uložte heslo místnosti", "membersHeadingWithCount": "Členové ({{count}})", "collapseRoomList": "Sbalit seznam místností", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 1f2e3a6a8..68b012f2d 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -4537,7 +4537,7 @@ "legendNotSavedTooltip": "Leerer Kreis — kein Passwort für diesen Raum gespeichert", "legendSavedTooltip": "Himmelblauer Halbkreis — Passwort gespeichert; melden Sie sich an, um eine Sitzung zu öffnen", "legendLoggedInTooltip": "Grüner Punkt — aktive Client-Sitzung (Raum verlassen, wenn der Server offline ist)", - "historyLocalHint": "Beiträge werden nur angezeigt, wenn Sie auf diesem Gerät in diesem Raum angemeldet sind. Aktivieren Sie die automatische Synchronisierung, um abonniert zu bleiben; der Verlauf wird nicht vom Raumserver zurückgegeben.", + "historyLocalHint": "Bei der Anmeldung fordert mesh-client aktuelle Beiträge an, die noch im Ringpuffer des Raumservers liegen (wenn dieses Gerät noch kein lokales Wasserzeichen hat). Aktivieren Sie die automatische Synchronisierung, um bei Verbindung abonniert zu bleiben.", "loginAllSavedDisabledNoSavedPasswords": "Speichern Sie zuerst ein Raumpasswort, um alle Anmeldedaten zu verwenden", "membersHeadingWithCount": "Mitglieder ({{count}})", "collapseRoomList": "Raumliste ausblenden", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 59f12db2d..d19080efd 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -4844,7 +4844,7 @@ "rememberPassword": "Remember password", "autoSync": "Auto-sync", "autoSyncTooltip": "Periodically re-login to fetch new posts when the radio is connected", - "historyLocalHint": "Posts appear only while you are logged in to this room on this device. Enable Auto-sync to stay subscribed; history is not backfilled from the room server.", + "historyLocalHint": "On login, mesh-client requests recent posts still in the room server ring buffer (when this device has no local watermark yet). Enable Auto-sync to stay subscribed while connected.", "syncIntervalLabel": "Sync interval", "syncInterval60": "Every 60 minutes", "syncInterval120": "Every 2 hours", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 174ef153d..9b1124284 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -4537,7 +4537,7 @@ "legendNotSavedTooltip": "Círculo vacío — no hay contraseña almacenada para esta sala", "legendSavedTooltip": "Semicírculo celeste — contraseña guardada; inicia sesión para abrir una sesión", "legendLoggedInTooltip": "Punto verde — sesión de cliente activa (abandona la sala si el servidor está fuera de línea)", - "historyLocalHint": "Las publicaciones solo aparecen mientras está conectado a esta sala en este dispositivo. Habilite la sincronización automática para permanecer suscrito; el historial no se rellena desde el servidor de la sala.", + "historyLocalHint": "Al iniciar sesión, mesh-client solicita publicaciones recientes que aún estén en el búfer circular del servidor de la sala (cuando este dispositivo aún no tiene marca de agua local). Habilite la sincronización automática para permanecer suscrito mientras esté conectado.", "loginAllSavedDisabledNoSavedPasswords": "Guarde primero una contraseña de sala para usar el inicio de sesión en todo", "membersHeadingWithCount": "Miembros ({{count}})", "collapseRoomList": "Contraer lista de salas", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 25f633e2a..d2fe4c662 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -4537,7 +4537,7 @@ "legendNotSavedTooltip": "Cercle vide — aucun mot de passe stocké pour cette salle", "legendSavedTooltip": "Demi-cercle bleu ciel — mot de passe enregistré ; connectez-vous pour ouvrir une session", "legendLoggedInTooltip": "Point vert — session client active (quittez la salle si le serveur est hors ligne)", - "historyLocalHint": "Les publications n'apparaissent que lorsque vous êtes connecté à cette pièce sur cet appareil. Activez la synchronisation automatique pour rester abonné ; l'historique n'est pas remblayé à partir du serveur de la salle.", + "historyLocalHint": "À la connexion, mesh-client demande les publications récentes encore présentes dans le tampon circulaire du serveur de salle (lorsque cet appareil n’a pas encore de filigrane local). Activez la synchronisation automatique pour rester abonné pendant la connexion.", "loginAllSavedDisabledNoSavedPasswords": "Enregistrez d'abord un mot de passe de pièce pour utiliser la connexion tout", "membersHeadingWithCount": "Membres ({{count}})", "collapseRoomList": "Réduire la liste des salles", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 3ea32ffb4..529198681 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -4537,7 +4537,7 @@ "legendNotSavedTooltip": "Lingkaran kosong — tidak ada kata sandi yang disimpan untuk ruangan ini", "legendSavedTooltip": "Setengah lingkaran biru langit — kata sandi disimpan; masuk untuk membuka sesi", "legendLoggedInTooltip": "Titik hijau — sesi klien aktif (tinggalkan ruangan jika server offline)", - "historyLocalHint": "Pos hanya muncul saat Anda masuk ke ruangan ini di perangkat ini. Aktifkan Sinkronisasi otomatis untuk tetap berlangganan; riwayat tidak diisi ulang dari server ruangan.", + "historyLocalHint": "Saat login, mesh-client meminta pos terbaru yang masih ada di buffer cincin server ruangan (ketika perangkat ini belum punya watermark lokal). Aktifkan Sinkronisasi otomatis agar tetap berlangganan saat terhubung.", "loginAllSavedDisabledNoSavedPasswords": "Simpan kata sandi ruangan terlebih dahulu untuk menggunakan log masuk semua", "membersHeadingWithCount": "Anggota ({{count}})", "collapseRoomList": "Ciutkan daftar ruangan", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 9273f7f27..a056cb254 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -4537,7 +4537,7 @@ "legendNotSavedTooltip": "Cerchio vuoto — nessuna password memorizzata per questa stanza", "legendSavedTooltip": "Semicerchio azzurro — password memorizzata; accedi per aprire una sessione", "legendLoggedInTooltip": "Punto verde — sessione client attiva (esci dalla stanza se il server è offline)", - "historyLocalHint": "I post vengono visualizzati solo quando si è connessi a questa stanza su questo dispositivo. Abilita la sincronizzazione automatica per rimanere iscritto; la cronologia non viene riempita dal server della stanza.", + "historyLocalHint": "All’accesso, mesh-client richiede i post recenti ancora presenti nel buffer ad anello del server della stanza (quando questo dispositivo non ha ancora una filigrana locale). Abilita la sincronizzazione automatica per rimanere iscritto mentre sei connesso.", "loginAllSavedDisabledNoSavedPasswords": "Salva prima una password della stanza per utilizzare l'accesso a tutti", "membersHeadingWithCount": "Membri ({{count}})", "collapseRoomList": "Comprimi l'elenco delle sale", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 79d97953f..ac0bc4f21 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -4537,7 +4537,7 @@ "legendNotSavedTooltip": "空の円—このルームにはパスワードが保存されていません", "legendSavedTooltip": "空色の半円 — パスワード保存済み。ログインしてセッションを開く", "legendLoggedInTooltip": "緑の点 — アクティブなクライアントセッション(サーバーがオフラインの場合はルームを退出)", - "historyLocalHint": "投稿は、このデバイスでこのルームにログインしている間にのみ表示されます。自動同期を有効にして購読したままにします。履歴はルームサーバーから埋め戻されません。", + "historyLocalHint": "ログイン時、mesh-client はこのデバイスにまだローカルのウォーターマークがない場合、ルームサーバーのリングバッファに残っている最近の投稿を要求します。接続中に購読を維持するには自動同期を有効にしてください。", "loginAllSavedDisabledNoSavedPasswords": "すべてログインするには、まずルームパスワードを保存してください", "membersHeadingWithCount": "メンバー( {{count}} )", "collapseRoomList": "ルームリストを折りたたむ", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 462c28375..62b8671a2 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -4537,7 +4537,7 @@ "legendNotSavedTooltip": "빈 원 — 이 룸에 대한 비밀번호가 저장되지 않음", "legendSavedTooltip": "하늘 반원 — 비밀번호가 저장되었습니다. 로그인하여 세션을 여세요", "legendLoggedInTooltip": "녹색 점 — 활성 클라이언트 세션 (서버가 오프라인이면 룸을 나가세요)", - "historyLocalHint": "게시물은 이 기기에서 이 룸에 로그인한 경우에만 표시됩니다. 자동 동기화를 활성화하여 구독 상태를 유지하세요. 기록은 룸 서버에서 채워지지 않습니다.", + "historyLocalHint": "로그인 시 mesh-client는 이 기기에 아직 로컬 워터마크가 없을 때 룸 서버 링 버퍼에 남아 있는 최근 게시물을 요청합니다. 연결된 동안 구독을 유지하려면 자동 동기화를 활성화하세요.", "loginAllSavedDisabledNoSavedPasswords": "전체 로그인을 사용하려면 먼저 룸 비밀번호를 저장하세요.", "membersHeadingWithCount": "회원 ({{count}})", "collapseRoomList": "방 목록 접기", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 0eae033af..bd9b96641 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -4537,7 +4537,7 @@ "legendNotSavedTooltip": "Lege cirkel — geen wachtwoord opgeslagen voor deze ruimte", "legendSavedTooltip": "Lichtblauwe halve cirkel — wachtwoord opgeslagen; log in om een sessie te openen", "legendLoggedInTooltip": "Groene stip — actieve clientsessie (verlaat de ruimte als de server offline is)", - "historyLocalHint": "Berichten worden alleen weergegeven terwijl u bent ingelogd in deze ruimte op dit apparaat. Schakel automatisch synchroniseren in om geabonneerd te blijven; de geschiedenis wordt niet opnieuw ingevuld vanaf de roomserver.", + "historyLocalHint": "Bij het inloggen vraagt mesh-client recente berichten op die nog in de ringbuffer van de roomserver staan (wanneer dit apparaat nog geen lokaal watermerk heeft). Schakel automatisch synchroniseren in om geabonneerd te blijven terwijl u verbonden bent.", "loginAllSavedDisabledNoSavedPasswords": "Sla eerst een ruimtewachtwoord op om alles in te loggen", "membersHeadingWithCount": "Leden ({{count}})", "collapseRoomList": "Ruimtelijst samenvouwen", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 644b66b68..fcf589d07 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -4541,7 +4541,7 @@ "legendNotSavedTooltip": "Pusty okrąg — nie zapisano hasła do tego pokoju", "legendSavedTooltip": "Niebieski półokrąg — hasło zapisane; zaloguj się, aby otworzyć sesję", "legendLoggedInTooltip": "Zielona kropka — aktywna sesja klienta (opuść pokój, jeśli serwer jest offline)", - "historyLocalHint": "Wpisy pojawiają się tylko wtedy, gdy jesteś zalogowany do tego pokoju na tym urządzeniu. Włącz automatyczną synchronizację, aby pozostać subskrybentem; historia nie jest uzupełniana z serwera pokojowego.", + "historyLocalHint": "Po zalogowaniu mesh-client żąda ostatnich wpisów nadal znajdujących się w buforze pierścieniowym serwera pokoju (gdy to urządzenie nie ma jeszcze lokalnego znaku wodnego). Włącz automatyczną synchronizację, aby pozostać subskrybentem podczas połączenia.", "loginAllSavedDisabledNoSavedPasswords": "Najpierw zapisz hasło do pokoju, aby użyć log in all", "membersHeadingWithCount": "Członkowie ({{count}})", "collapseRoomList": "Zwiń listę pokoi", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index f3facb5d8..2e67e1964 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -4537,7 +4537,7 @@ "legendNotSavedTooltip": "Círculo vazio — nenhuma senha armazenada para esta sala", "legendSavedTooltip": "Meio círculo azul-céu — senha armazenada; faça login para abrir uma sessão", "legendLoggedInTooltip": "Ponto verde — sessão ativa do cliente (saia da sala se o servidor estiver offline)", - "historyLocalHint": "As postagens aparecem apenas enquanto você está conectado a esta sala neste dispositivo. Ative a sincronização automática para permanecer subscrito; o histórico não é preenchido a partir do servidor da sala.", + "historyLocalHint": "Ao entrar, o mesh-client solicita publicações recentes ainda no buffer circular do servidor da sala (quando este dispositivo ainda não tem marca d’água local). Ative a sincronização automática para permanecer subscrito enquanto estiver conectado.", "loginAllSavedDisabledNoSavedPasswords": "Salve uma senha de sala primeiro para usar o login em todos", "membersHeadingWithCount": "Membros ({{count}})", "collapseRoomList": "Recolher lista de salas", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 0d7737b2a..307bfea40 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -4539,7 +4539,7 @@ "legendNotSavedTooltip": "Пустой круг — пароль для этой комнаты не сохранен", "legendSavedTooltip": "Полукруг неба — пароль сохранен; войдите, чтобы открыть сессию", "legendLoggedInTooltip": "Зелёная точка — активная клиентская сессия (покиньте комнату, если сервер не в сети)", - "historyLocalHint": "Публикации появляются только при входе в эту комнату на этом устройстве. Включите автоматическую синхронизацию, чтобы оставаться подписанным; история не заполняется с сервера комнаты.", + "historyLocalHint": "При входе mesh-client запрашивает недавние публикации, ещё находящиеся в кольцевом буфере сервера комнаты (если на этом устройстве ещё нет локальной метки). Включите автоматическую синхронизацию, чтобы оставаться подписанным во время подключения.", "loginAllSavedDisabledNoSavedPasswords": "Сначала сохраните пароль комнаты, чтобы использовать вход во все", "membersHeadingWithCount": "Участники ({{count}})", "collapseRoomList": "Свернуть список комнат", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index f21d09097..84be787f0 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -4537,7 +4537,7 @@ "legendNotSavedTooltip": "Boş daire — bu oda için saklanan parola yok", "legendSavedTooltip": "Gökyüzü yarım daire — parola saklanır; bir oturumu açmak için oturum açın", "legendLoggedInTooltip": "Yeşil nokta — aktif istemci oturumu (sunucu çevrimdışıysa odayı terk edin)", - "historyLocalHint": "Gönderiler yalnızca bu cihazda bu odaya giriş yaptığınızda görünür. Abone kalmak için Otomatik senkronizasyonu etkinleştirin; geçmiş oda sunucusundan doldurulmaz.", + "historyLocalHint": "Girişte mesh-client, bu cihazda henüz yerel filigran yokken oda sunucusu halka belleğinde kalan son gönderileri ister. Bağlıyken abone kalmak için Otomatik senkronizasyonu etkinleştirin.", "loginAllSavedDisabledNoSavedPasswords": "Tüm oturum açma bilgilerini kullanmak için önce bir oda parolasını kaydedin", "membersHeadingWithCount": "Üyeler ({{count}})", "collapseRoomList": "Oda listesini daralt", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index d0a789c1b..8c64ed1c8 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -4539,7 +4539,7 @@ "legendNotSavedTooltip": "Порожнє коло — для цієї кімнати не збережено пароль", "legendSavedTooltip": "Напівкруг неба — пароль збережено; увійдіть, щоб відкрити сеанс", "legendLoggedInTooltip": "Зелена крапка — активна клієнтська сесія (вийдіть із кімнати, якщо сервер не в мережі)", - "historyLocalHint": "Дописи з'являються лише під час входу в цю кімнату на цьому пристрої. Увімкніть автоматичну синхронізацію, щоб залишатися передплаченим; історія не заповнюється з сервера кімнати.", + "historyLocalHint": "Під час входу mesh-client запитує недавні дописи, що ще є в кільцевому буфері сервера кімнати (коли на цьому пристрої ще немає локальної мітки). Увімкніть автоматичну синхронізацію, щоб залишатися передплаченим під час з’єднання.", "loginAllSavedDisabledNoSavedPasswords": "Спочатку збережіть пароль кімнати, щоб увійти в усі", "membersHeadingWithCount": "Учасники ({{count}})", "collapseRoomList": "Згорнути список кімнат", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 376d42f01..f141364f2 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -4537,7 +4537,7 @@ "legendNotSavedTooltip": "空圆—此房间未存储密码", "legendSavedTooltip": "天空半圆—密码已存储;登录以打开会话", "legendLoggedInTooltip": "绿点—活动客户端会话(服务器离线时请离开房间)", - "historyLocalHint": "仅当您在此设备上登录此聊天室时,帖子才会显示。启用自动同步以保持订阅状态;历史记录不会从聊天室服务器回填。", + "historyLocalHint": "登录时,若此设备尚无本地水印,mesh-client 会请求聊天室服务器环形缓冲区中仍保留的最近帖子。启用自动同步以在连接期间保持订阅。", "loginAllSavedDisabledNoSavedPasswords": "首先保存房间密码以使用全部登录", "membersHeadingWithCount": "成员({{count}})", "collapseRoomList": "折叠房间列表", diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index d6a382af4..29e98ec2d 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -303,7 +303,10 @@ import { MESHCORE_ROOM_CREDENTIAL_SETTING_PREFIX, setMeshcoreRoomCredential, } from '../lib/meshcoreRoomCredentialStorage'; -import { syncMeshcoreRoomContactPathBeforeLogin } from '../lib/meshcoreRoomLoginPathSync'; +import { + resetMeshcoreRoomCompanionSyncSinceForCatchUp, + syncMeshcoreRoomContactPathBeforeLogin, +} from '../lib/meshcoreRoomLoginPathSync'; import { meshcoreIsRoomLoginQueued } from '../lib/meshcoreRoomLoginQueue'; import { resolveMeshcoreRoomLoginRouteBytes } from '../lib/meshcoreRoomLoginRouteResolve'; import { applyMeshcoreRoomLoginFailure } from '../lib/meshcoreRoomSavedSecrets'; @@ -337,6 +340,7 @@ import { } from '../lib/meshcoreRoomSession'; import { pickMostOverdueRoom, type RoomSyncSchedulerNode } from '../lib/meshcoreRoomSyncScheduler'; import { + getMeshcoreRoomLastPostAt, getMeshcoreRoomSyncConfig, listMeshcoreRoomAutoLoginOnConnectNodeIds, listMeshcoreRoomSyncEnabledNodeIds, @@ -6254,6 +6258,13 @@ export function useMeshcoreRuntime() { console.debug( `[useMeshcoreRuntime] loginRoom pathSync node=0x${nodeId.toString(16)} ${JSON.stringify(pathSync)} storedPathLen=${storedPath?.length ?? 0}`, ); + // No local posts yet → zero companion sync_since so login requests ring-buffer catch-up. + if (getMeshcoreRoomLastPostAt(nodeId) == null) { + await meshcoreAbortablePromise( + resetMeshcoreRoomCompanionSyncSinceForCatchUp(activeConn, nodeId, pubKey), + loginAbortSignal, + ); + } if (opts?.abortIfStale?.()) { throw new DOMException(MESHCORE_ROOM_LOGIN_ABORT_MESSAGE, 'AbortError'); } @@ -6288,6 +6299,27 @@ export function useMeshcoreRuntime() { }); } clearMeshcoreRoomAutoLoginFailure(nodeId); + // Room servers begin pushing ring-buffer posts ~2s after LoginSuccess; drain may have + // been busy/timed out during SendLogin — kick silent drains to ingest history. + for (const delayMs of [2_500, 8_000, 20_000]) { + window.setTimeout(() => { + scheduleMeshcoreWaitingMessagesDrain( + async () => { + try { + await processWaitingMessagesRef.current?.({ showSyncBanner: false }); + } catch (e: unknown) { + // catch-no-log-ok logMeshcoreWaitingMessagesDrainError handles logging + logMeshcoreWaitingMessagesDrainError( + 'post-login room history drain failed', + e, + false, + ); + } + }, + { isMounted: () => meshcoreHookMountedRef.current }, + ); + }, delayMs); + } })(), MESHCORE_ROOM_LOGIN_TOTAL_TIMEOUT_MS, 'loginRoom', @@ -6725,7 +6757,7 @@ export function useMeshcoreRuntime() { const storeId = meshcoreIdentityIdRef.current; const canonicalId = addMessage(tempMsg); try { - const hopsAway = getIdentityNode(meshcoreIdentityIdRef.current, nodeId)?.hops_away ?? 0; + const hopsAway = resolveRoomLoginHopsForNode(nodeId); console.debug( `[useMeshcoreRuntime] sendRoomPost mode=post txtType=${MESHCORE_TXT_TYPE_PLAIN} bodyLen=${new TextEncoder().encode(text).length} room=0x${nodeId.toString(16)} hops=${hopsAway} transport=${meshcoreConnectTypeRef.current ?? 'unknown'}`, ); From 465fa644b7e0ac0cf6c210346631ef3f12bc5437 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sat, 15 Aug 2026 12:53:01 -0600 Subject: [PATCH 8/9] fix(meshcore): harden room login abort, catch-up restore, and shared sync path Cancel now interrupts TX-spacing and in-flight SendLogin; catch-up retries companion restore after remove; auto-sync/reconnect reuse loginRoom. Also bump @zip.js/zip.js from pnpm update. --- docs/agents/meshcore-rooms.md | 6 +- docs/agents/reticulum.md | 2 +- package.json | 2 +- patches/@liamcottle__meshcore.js@1.14.0.patch | 2 +- pnpm-lock.yaml | 32 ++-- src/renderer/components/RoomsPanel.test.tsx | 28 ++++ ...meshcoreHookPreamble.resolvePubKey.test.ts | 59 ++++++- .../hooks/meshcore/meshcoreHookPreamble.ts | 33 +++- .../lib/meshcoreRoomLoginPathSync.test.ts | 52 ++++++ src/renderer/lib/meshcoreRoomLoginPathSync.ts | 55 +++++-- .../meshcoreRoomLoginQueue.spacing.test.ts | 31 ++++ src/renderer/lib/meshcoreRoomLoginQueue.ts | 18 ++- src/renderer/lib/meshcoreRoomSession.test.ts | 70 ++++++++ src/renderer/lib/meshcoreRoomSession.ts | 52 +++--- src/renderer/lib/timeConstants.ts | 3 + src/renderer/runtime/useMeshcoreRuntime.ts | 150 +++++------------- 16 files changed, 422 insertions(+), 173 deletions(-) create mode 100644 src/renderer/lib/meshcoreRoomLoginQueue.spacing.test.ts diff --git a/docs/agents/meshcore-rooms.md b/docs/agents/meshcore-rooms.md index 39f54130f..a0c0d0f96 100644 --- a/docs/agents/meshcore-rooms.md +++ b/docs/agents/meshcore-rooms.md @@ -2,12 +2,12 @@ Deep subsystem reference for AI assistants. Open this when a task touches MeshCore Rooms login/post, session RPCs, saved passwords, auto-sync scheduling, or room wire text. Hard rules live in [`AGENTS.md`](../../AGENTS.md). -- **UI:** `RoomsPanel.tsx` — login overlay, post composer (`ChatComposer`), auto-sync toggles, Members ACL refresh (`get acl`); **Manage on Repeaters** jumps to Repeaters & Rooms ops (CLI / setperm / infra). Sidebar badge via `meshcoreRoomsUnread.ts` (`mesh-client:meshcoreRoomsUnread`). Blank guest **Login** = read-only when allowed; **`"hello"`** is the default read/write guest password; **Continue read-only** also sends blank. **Cancel login** aborts the outer login op (path resolve + SendLogin) via `meshcoreBeginRoomLoginOperation` / `meshcoreCancelRoomLogin`. +- **UI:** `RoomsPanel.tsx` — login overlay, post composer (`ChatComposer`), auto-sync toggles, Members ACL refresh (`get acl`); **Manage on Repeaters** jumps to Repeaters & Rooms ops (CLI / setperm / infra). Sidebar badge via `meshcoreRoomsUnread.ts` (`mesh-client:meshcoreRoomsUnread`). Blank guest **Login** = read-only when allowed; **`"hello"`** is the default read/write guest password; **Continue read-only** also sends blank. **Cancel login** aborts the outer login op (path resolve + SendLogin) via `meshcoreBeginRoomLoginOperation` / `meshcoreCancelRoomLogin`; queue TX-spacing waits are interruptible so Cancel does not sit out the 60s mesh spacing. - **Ops (not on Rooms tab):** Ping / Status / Neighbors / Sensor / CLI / ACL setperm live on `RepeatersPanel` (mixed Repeater + Room table). **Open room** on a room row jumps back to Rooms. Guest passwords stay in `meshcoreRoomCredentialStorage`; ops admin uses `meshcoreInfraAdminSecrets` (admin-only records allowed). - **Room remote CLI:** needs room **admin** password via SendLogin ACL (`meshcoreCancelRoomLogin` then admin login inside `sendRepeaterCliCommand`); guest BBS login is not enough (`repeatersPanel.roomCliNeedsAdminPassword`). - **Post length (single-packet):** room posts use the shared `ChatComposer` (`variant="room"`) and are **single-packet** like MeshCore chat — over the room payload limit (`getMeshcoreRoomPayloadLimit` in `chatComposerLimits.ts`) the send is blocked with the same `meshcoreSingleNotice` callout rather than split into `[i/N]` parts, and the ~5s fast-send advisory applies. See [`chat.md`](chat.md) (Composer limits / send cadence). -- **Session / RPC:** `meshcoreRoomSession.ts`, `meshcoreRoomLoginRpc.ts`, `meshcoreRoomPostRpc.ts`, `meshcoreRoomLogoutRpc.ts`, `meshcoreRoomLoginQueue.ts`, `meshcoreRoomLoginPathSync.ts`, `meshcoreRoomSentWait.ts`; credentials in `meshcoreRoomCredentialStorage.ts` / `meshcoreRoomSyncStorage.ts`. +- **Session / RPC:** `meshcoreRoomSession.ts`, `meshcoreRoomLoginRpc.ts`, `meshcoreRoomPostRpc.ts`, `meshcoreRoomLogoutRpc.ts`, `meshcoreRoomLoginQueue.ts`, `meshcoreRoomLoginPathSync.ts`, `meshcoreRoomSentWait.ts`; credentials in `meshcoreRoomCredentialStorage.ts` / `meshcoreRoomSyncStorage.ts`. **LoginSuccess:** prefer v7+ `permissions` (PERM_ACL_*); `reserved` is the legacy admin/guest hint (0=RW, 1=admin, 2=guest) — do not treat it as ACL. **Hops:** `resolveMeshcoreRoomLoginHopsAway` uses route bytes when UI hops are 0, and treats sticky UI hops with an empty path as 0-hop so SendLogin is not blocked as noRoute. **History catch-up:** `resetMeshcoreRoomCompanionSyncSinceForCatchUp` remove+re-adds the companion contact (firmware zeroes `sync_since` only on new contacts) when this device has no local last-post watermark; add is retried if remove already succeeded so the contact is not left deleted. - **Saved passwords:** `meshcoreRoomSavedSecrets.ts` — sidebar/overlay **Forget** / **Stop auto-login**; `forgetMeshcoreRoomSavedSecrets` clears credential + disables auto-login and auto-sync; `disableMeshcoreRoomLoginAfterAuthFailure` disables both without clearing password or in-memory failure UI. -- **Scheduler:** `meshcoreRoomSyncScheduler.ts` + `useMeshcoreRuntime.ts` — periodic re-login (Auto-sync, RF-only); single-flight ticks; background route resolve uses `skipTrace` / `MESHCORE_ROOM_SYNC_ROUTE_RESOLVE_FAST_MS`. Auth failure disables auto-sync and auto-login via `disableMeshcoreRoomLoginAfterAuthFailure`. Timeouts in `timeConstants.ts` (shorter for TCP / 0-hop). +- **Scheduler:** `meshcoreRoomSyncScheduler.ts` + `useMeshcoreRuntime.ts` — periodic re-login (Auto-sync, RF-only) goes through the same `loginRoom` path as the UI (path sync, catch-up, abort, waiting-message drain) with `schedulerFastPath` (skipTrace / `MESHCORE_ROOM_SYNC_ROUTE_RESOLVE_FAST_MS`). Single-flight ticks. Auth failure disables auto-sync and auto-login via `disableMeshcoreRoomLoginAfterAuthFailure`. Timeouts in `timeConstants.ts` (shorter for TCP / 0-hop). - **Connect auto-login:** `meshcoreRoomAutoLoginOnConnect.ts` + `useMeshcoreRuntime.ts` — 500ms debounce (`MESHCORE_ROOM_AUTO_LOGIN_DEBOUNCE_MS`) when Room contacts appear (`roomAutoLoginReadyKey`, including pubkey-ready). Overlapping triggers share one pass (`runMeshcoreRoomAutoLoginSingleFlight`) and dirty-re-run so a second room hydrating mid-pathSync still logs in. Disconnect bumps a generation so a dying pass must not `SendLogin` on the new conn. Skips logged-in, queued (`meshcoreIsRoomLoginQueued`), failed (`getMeshcoreRoomAutoLoginFailure`), and not-yet-hydrated contacts. - **Wire text:** `meshcoreChannelText.ts` — channel/DM/room payloads, SignedPlain inbound strip, tapback/reply lines; `meshcoreGifWire.ts` — Open `g:GIFID`; `meshcoreOpenReaction.ts` — Open `r:HASH:INDEX`. Default companion keyless outbound; opt-in Open wire via Radio `meshcoreOpenWireCompatEnabled` (UI on `RadioPanel`). diff --git a/docs/agents/reticulum.md b/docs/agents/reticulum.md index d38b58d36..84208a621 100644 --- a/docs/agents/reticulum.md +++ b/docs/agents/reticulum.md @@ -12,7 +12,7 @@ Deep subsystem reference for AI assistants. Open this when a task touches the Re - **Propagation mode / sync:** Network → Propagation nodes owns Off/Auto/Manual (default **Off**; persisted values including legacy App-panel `auto` are honored). Auto one-time syncs via `startPropagationSyncCascade` + sidecar `destination_hash` sync in order: **finite-hop discovered** (no Add/Preferred) → **configured remotes** → **unknown-hop discovered** → local-prop (skips remotes when no enabled interfaces); runtime hook `useReticulumPropagationAutoSync`. Sidecar `start_propagation_sync` is **client `/get`-primary** (inbox retrieval; UI progress from `PropagationClient`) — peer `/offer` inventory push stays on the local-host peer loop when serving (avoids AwaitingResponse hangs against non-peer remotes with a nonempty messagestore). Hard-fails with `PROPAGATION_PATH_UNKNOWN` when `ensure_path_for_direct` fails after announce settle (same path gate as offer probe). Manual uses Preferred, else picks the best configured remote **for that sync only** (no Preferred write), then the remaining remotes, then local-prop. Off = **no PN support**: `startPropagationSyncCascade` returns early (per-row Sync is disabled in UI), `hasEffectiveReticulumPropagationTarget` / `hasReticulumPnCascadeCapacity` are false, `ReticulumPropagationNotice` is hidden, and the sidecar disarms the outbound PN plus empties cascade candidates (`propagation_mode` in `mesh_client_stack.json`, `POST /api/v1/propagation/mode`, `candidates_for_propagation_mode`); renderer pushes the mode on change and on sidecar-ready. **Ignore for Auto:** `POST/DELETE /api/v1/propagation/auto-blacklist` persists `propagation_auto_blacklist` (32-hex, cap 256); filters Auto sync ranking **and** Auto deposit (`auto_discovered_candidates` + configured retain in Auto); Manual Prefer/Sync still allowed. Ownership: mode in renderer localStorage (+ sidecar mirror); blacklist + deposit candidates in sidecar; sync cascade orchestration in `reticulumPropagationAutoApply.ts`; `startSync` attempt stamps must be unique across same-ms supersession. `reticulumPropagationStore` / `reticulumPropagationSync.ts` — Establishing stall (~45s) + hard ceiling (~180s), auto-sync interval from last success with failure cooldown, error keys for identity / non-PN / path-unknown / peering stamp; stamps `lastPropagationSyncAttemptAt` / `activePropagationSyncAttemptAt` for WS correlation. Cancel mid-`/get` must call `PropagationClient::abort_transfer` (rsLXMF overlay) or the next Sync stays `PROPAGATION_RETRIEVE_BUSY`. Silent Host `/get` terminal clear must only drop the latch when `propagation_sync_target` still equals that peer. **Nothing-to-sync is not a failure:** when the cascade contacts no node it writes `syncNoTarget` / `syncLocalLoading` / `syncRetrieveBusy` (never overwriting a real error from an attempted node), the local row reports sidecar `status: "loading"` while the messagestore reads (`local_propagation_status` + `PropagationBridge::messagestore_load_pending`, per-row Sync disabled), and the 30 s tick calls `refreshFromSidecar` while `hasPropagationCascadeCandidate` is false so a fresh stack recovers on its own — `refreshFromSidecar` must **not** clear the active attempt while `sync.active`. Debug snapshot `propagationClient` exposes mode/preferred/autoTarget/resolvedSyncTargetId/autoBlacklist. **Auto also deposits on Discovered PNs:** sidecar `auto_discovered_candidates` (`pn_cascade.rs`, Auto only, cap 3, hop-sorted with `MAX_PLAUSIBLE_PROPAGATION_HOPS=32`, skips inactive / self / already-configured / Auto-blacklist / over `max_peering_cost`) appends after configured remotes and before local-prop, rebuilt from the shared `rebuild_pn_cascade_candidates` helper in `live.rs`; capacity helpers count non-blacklisted discovered rows in Auto. **Chat notice dismiss:** `chatNoticeDismissed` with **Don't show again** / Network toggle. **Named sync target:** `startSync` stamps `syncTargetId`. **Attempts settle before the cascade advances:** `startSync` returns `accepted` | `deferred` | `failed`. Soft-defer (`PROPAGATION_SYNC_OUTBOUND_BUSY` / `PROPAGATION_RETRIEVE_BUSY` / `PROPAGATION_STACK_NOT_LIVE`) advances **without** 15‑min backoff; all-remote soft-defer + local-only settle must **not** advance `lastPropagationSyncAt` as a full success. Remote budget `PROPAGATION_CASCADE_BUDGET_MS` (5 min); per-attempt ~60s; single-flight cascade. Auto `/api/v1/interfaces` probe **fails closed** (treat read/rate-limit as no interfaces → local-only settle). **Retrieval vs peer sync:** User Sync progress is **client `/get`-primary**. Peer `/offer` runs only on the **local Host peer loop**. Logs: `propagation-retrieve` (`retrieve_mode=get|get_post_peer|get_periodic|local`); peer-offer `propagation-sync … peer_outcome=*` (**not** retrieval). `local-prop` Sync uses `drain_local_inbox` and returns `PROPAGATION_STACK_NOT_LIVE` when live is absent. - **Host PN fabric → Chat (lxmd-style glue):** When local Host is enabled, mesh-client is both PN and end-client on rsLXMF (not a second lxmd). Path: outbound deposit → host peer `/offer` push (generation-gated; lxmd terminal bookkeeping: `sync_complete` / `mark_offer_generation_processed` / `take_handled_updates` + `save_peer`); inbound peer Resource accept → `request_inbox_drain` → maintenance `drain_local_inbox` → `delivery_callback` → Chat; after host peer `/offer` Completes for peer `P`, sequenced **silent** client `/get` (`retrieve_mode=get_post_peer`); while serving and quiet, **~90s** periodic silent `/get` round-robin over peered remotes (Prefer/outbound first, `retrieve_mode=get_periodic`) for inbox catch-up — not a timed empty re-`/offer`. Re-`/offer` when `offer_generation` advances, offer policy changes, or a partial sync left work; maintenance polls every ~2s but only _starts_ work when idle. Guards: coalesce drain; one internal `/get`; skip when user Sync target / outbound deposit owns the hash or `sync_active` / `client_download_active`. Do **not** re-attach peer `/offer` to the Sync button. Dual full-index exchange in one Link stays upstream rsLXMF; remote inventory for re-propagation arrives when peers `/offer` to our serve path. - **PN hosting:** Network **Advanced PN hosting** / `ReticulumPnHostingDangerZone`; shared `pnHostingPolicy.ts` + sidecar `pn_hosting_policy.rs` / `pn_hosting_apply.rs`; `POST /api/v1/propagation/hosting-policy`; rsLXMF policy-setters overlay ([ratspeak/rsLXMF#6](https://github.com/ratspeak/rsLXMF/pull/6)). Messagestore loads in background on live attach; enabled `local-prop` serve/announce waits until load completes. -- **Interface modes:** rnsd `mode` via `reticulumInterfaceMode.ts` + sidecar `normalize_interface_mode` (keep catalogs in sync — `pnpm run check:reticulum-interface-modes` in pre-commit/`release.sh`); add defaults TCP/UDP/I2P → `boundary`, RNode → `access_point`; UI in `ReticulumInterfacesPanel`; default hub presets add/repair missing mode to `boundary` (do not overwrite valid non-boundary). See [../reticulum.md#interface-modes](../reticulum.md#interface-modes). +- **Interface modes:** rnsd `mode` via `reticulumInterfaceMode.ts` + sidecar `normalize_interface_mode` (keep catalogs in sync — `pnpm run check:reticulum-interface-modes` in pre-commit/`release.sh`); add defaults TCP/UDP/I2P → `boundary`, RNode → `access_point`; UI in `ReticulumInterfacesPanel`; default hub presets add/repair missing mode to `boundary` (do not overwrite valid non-boundary). Discoverable + Full/Roaming/Boundary stamps `ignore_config_warnings = Yes` (`reconcile_ignore_config_warnings` / `repair_ignore_config_warnings_in_config`) so RNS does not auto-correct runtime mode to Access Point; Connection shows **Effective: Access Point** when live `runtime_mode` still diverges. See [../reticulum.md#interface-modes](../reticulum.md#interface-modes) and [../reticulum.md#rmap-publish-and-interface-mode](../reticulum.md#rmap-publish-and-interface-mode). - **Share instance defaults:** missing keys bootstrap to `share_instance = No` / `instance_name = mesh-client` (does not overwrite explicit Yes/`default`); SharedInstanceClient banner + `disable_share_instance` repair; offline lint via `reticulum:validateConfig` / Network **Check config** / `pnpm run reticulum:config:check` - **LXMF replies:** sidecar stamps `FIELD_REPLY_TO` / capped `FIELD_REPLY_QUOTE` before sign; renderer ingest/Chat use `reticulum_reply_to_hash` + quote preview + jump-by-hash - **RNode flasher timeouts:** `RNODE_COMMAND_TIMEOUT_MS` (30 s serial), `RNODE_BT_PAIRING_TIMEOUT_MS` (90 s BLE pairing), `ESP32_FLASH_STALL_TIMEOUT_MS` / `NRF52_DFU_STALL_TIMEOUT_MS` (60 s no-progress → `ESP32_FLASH_STALLED` / `NRF52_DFU_STALLED`); humanized via `flasherErrorHumanize.ts` diff --git a/package.json b/package.json index df430f93d..8f2afc6a1 100644 --- a/package.json +++ b/package.json @@ -147,7 +147,7 @@ "@stoprocent/noble": "^2.7.1", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", - "@zip.js/zip.js": "^2.8.47", + "@zip.js/zip.js": "^2.8.51", "builder-util-runtime": "9.7.0", "dompurify": "^3.4.13", "electron-updater": "^6.8.9", diff --git a/patches/@liamcottle__meshcore.js@1.14.0.patch b/patches/@liamcottle__meshcore.js@1.14.0.patch index 8a1b5f8d8..ff440781b 100644 --- a/patches/@liamcottle__meshcore.js@1.14.0.patch +++ b/patches/@liamcottle__meshcore.js@1.14.0.patch @@ -67,7 +67,7 @@ index 541ce732e52444d094043e49fef295752da3d7bc..209edb4f2cccff89efee83c708796a5c + }; + if (bufferReader.getRemainingBytesCount() >= 5) { + payload.serverTimestamp = bufferReader.readUInt32LE(); -+ payload.permissions = bufferReader.readByte(); // ACL permissions (data[7]) ++ payload.permissions = bufferReader.readByte(); // PERM_ACL_* after timestamp + if (bufferReader.getRemainingBytesCount() >= 1) { + payload.firmwareVerLevel = bufferReader.readByte(); + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50fcb1d71..4a1783d27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,7 +29,7 @@ overrides: patchedDependencies: '@jsr/meshtastic__core@2.6.6': 93604a080fa754cbde3dc78ef46ee50c2996c34d6d85720acacecfd6ebd7c652 '@jsr/meshtastic__transport-web-serial@0.2.5': 27a2418bae8605e0e5ab6f1fcfd391abd9dc3110433da5754e934f38475dab74 - '@liamcottle/meshcore.js@1.14.0': f54000776dc3e57c071056a4a29cec4a566f7f8368165ca313b147378669e39f + '@liamcottle/meshcore.js@1.14.0': cb795713dfd60da6415b04d0a81a863876163d355ca946b1a50440f0c38e2f49 debug@4.4.3: cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37 readable-stream@4.7.0: 96023d9278085d7490d08bce1a5079dd202f7782a0daa66fa81c6b1424ef8ab1 usb@2.18.0: 6b746e2d49b9b006a88aec5bed7a13c629d7f5ba7b40e9f1e039136754c32533 @@ -54,8 +54,8 @@ importers: specifier: ^6.0.0 version: 6.0.0 '@zip.js/zip.js': - specifier: ^2.8.47 - version: 2.8.47 + specifier: ^2.8.51 + version: 2.8.51 builder-util-runtime: specifier: 9.7.0 version: 9.7.0(supports-color@8.1.1) @@ -128,7 +128,7 @@ importers: version: 10.0.1(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) '@liamcottle/meshcore.js': specifier: ^1.14.0 - version: 1.14.0(patch_hash=f54000776dc3e57c071056a4a29cec4a566f7f8368165ca313b147378669e39f)(supports-color@8.1.1) + version: 1.14.0(patch_hash=cb795713dfd60da6415b04d0a81a863876163d355ca946b1a50440f0c38e2f49)(supports-color@8.1.1) '@meshtastic/core': specifier: npm:@jsr/meshtastic__core@^2.6.6 version: '@jsr/meshtastic__core@2.6.6(patch_hash=93604a080fa754cbde3dc78ef46ee50c2996c34d6d85720acacecfd6ebd7c652)(buffer@6.0.3)' @@ -1476,8 +1476,8 @@ packages: '@yarnpkg/lockfile@1.1.0': resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} - '@zip.js/zip.js@2.8.47': - resolution: {integrity: sha512-zQzidM6FD1teXUXL3Xy7g+b7k3jybQArLRVi2mIPt74JtxBVVPheDCW5q6nhEr4Pq+myXHoC9GsHIgxGQ6T8ow==} + '@zip.js/zip.js@2.8.51': + resolution: {integrity: sha512-xCEMRBwBh6JPMQKy5D/RE0Ra9NJvrvjcCHNXo+NzMYCdZy3R0oAX9zJKRb1c393HNMbLYfoD2MRSPgB8L6Jskw==} engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} abbrev@4.0.0: @@ -1630,8 +1630,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.13: - resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==} + baseline-browser-mapping@2.11.14: + resolution: {integrity: sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==} engines: {node: '>=6.0.0'} hasBin: true @@ -2044,8 +2044,8 @@ packages: electron-publish@26.15.3: resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==} - electron-to-chromium@1.5.405: - resolution: {integrity: sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==} + electron-to-chromium@1.5.406: + resolution: {integrity: sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg==} electron-updater@6.8.9: resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==} @@ -5086,7 +5086,7 @@ snapshots: transitivePeerDependencies: - buffer - '@liamcottle/meshcore.js@1.14.0(patch_hash=f54000776dc3e57c071056a4a29cec4a566f7f8368165ca313b147378669e39f)(supports-color@8.1.1)': + '@liamcottle/meshcore.js@1.14.0(patch_hash=cb795713dfd60da6415b04d0a81a863876163d355ca946b1a50440f0c38e2f49)(supports-color@8.1.1)': dependencies: '@noble/curves': 1.9.7 serialport: 13.0.0(supports-color@8.1.1) @@ -5787,7 +5787,7 @@ snapshots: '@yarnpkg/lockfile@1.1.0': {} - '@zip.js/zip.js@2.8.47': {} + '@zip.js/zip.js@2.8.51': {} abbrev@4.0.0: {} @@ -5992,7 +5992,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.13: {} + baseline-browser-mapping@2.11.14: {} bidi-js@1.0.3: dependencies: @@ -6027,9 +6027,9 @@ snapshots: browserslist@4.28.8: dependencies: - baseline-browser-mapping: 2.11.13 + baseline-browser-mapping: 2.11.14 caniuse-lite: 1.0.30001809 - electron-to-chromium: 1.5.405 + electron-to-chromium: 1.5.406 node-releases: 2.0.53 update-browserslist-db: 1.3.1(browserslist@4.28.8) @@ -6430,7 +6430,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-to-chromium@1.5.405: {} + electron-to-chromium@1.5.406: {} electron-updater@6.8.9(supports-color@8.1.1): dependencies: diff --git a/src/renderer/components/RoomsPanel.test.tsx b/src/renderer/components/RoomsPanel.test.tsx index d20f1971b..14924c35d 100644 --- a/src/renderer/components/RoomsPanel.test.tsx +++ b/src/renderer/components/RoomsPanel.test.tsx @@ -11,6 +11,7 @@ import { type StarredMessage, } from '@/renderer/lib/chatPanelProtocolStorage'; import { VIRTUALIZER_SCROLL_END_THRESHOLD } from '@/renderer/lib/chatScrollUtils'; +import { serializeMeshcoreUserMessage } from '@/renderer/lib/meshcore/meshcoreMessageI18n'; import { buildMeshcoreRoomIncomingMessage } from '@/renderer/lib/meshcoreChannelText'; import { clearAllMeshcoreRoomAutoLoginFailures, @@ -68,6 +69,10 @@ vi.mock('react-i18next', () => ({ if (key === 'meshcore.errors.roomLogin.noRoute') { return 'No route to this room server. Trace the node from the map or wait for path adverts, then try again.'; } + if (key === 'meshcore.errors.roomLogin.pathSyncFailedDetail') { + const detail = typeof opts?.detail === 'string' ? opts.detail : ''; + return `Could not program the route on your radio before login. Reconnect the device and try again.${detail}`; + } if (key === 'roomsPanel.autoLoginFailed' && typeof opts?.error === 'string') { return `Auto-login failed: ${opts.error}`; } @@ -303,6 +308,29 @@ describe('RoomsPanel', () => { expect(screen.queryByText('meshcore.errors.roomLogin.noRoute')).not.toBeInTheDocument(); }); + it('translates serialized pathSyncFailedDetail including radio error detail', async () => { + meshcoreClearAllRoomSessions(); + const room = makeRoom(0x1006, 'Path Sync Room'); + const nodes = new Map([[room.node_id, room]]); + const onLoginRoom = vi.fn().mockRejectedValue( + new Error( + serializeMeshcoreUserMessage({ + key: 'meshcore.errors.roomLogin.pathSyncFailedDetail', + params: { detail: ' (timeout)' }, + }), + ), + ); + renderRoomsPanel(nodes, { initialRoomTarget: room.node_id, onLoginRoom }); + fireEvent.click(screen.getByText('roomsPanel.loginButton')); + await waitFor(() => { + expect( + screen.getByText( + 'Could not program the route on your radio before login. Reconnect the device and try again. (timeout)', + ), + ).toBeInTheDocument(); + }); + }); + it('allows Login with empty guest password and sends blank', async () => { meshcoreClearAllRoomSessions(); const room = makeRoom(0x1004, 'Empty Guest Room'); diff --git a/src/renderer/hooks/meshcore/meshcoreHookPreamble.resolvePubKey.test.ts b/src/renderer/hooks/meshcore/meshcoreHookPreamble.resolvePubKey.test.ts index b36aa6cf9..887ced6c5 100644 --- a/src/renderer/hooks/meshcore/meshcoreHookPreamble.resolvePubKey.test.ts +++ b/src/renderer/hooks/meshcore/meshcoreHookPreamble.resolvePubKey.test.ts @@ -5,7 +5,10 @@ import { registerMeshcorePubKey, } from '../../lib/meshcore/meshcorePubKeyRegistry'; import { pubkeyToNodeId } from '../../lib/meshcoreUtils'; -import { resolveMeshcoreNodePubKey } from './meshcoreHookPreamble'; +import { + reloadMeshcorePubKeyIfNodeIdMismatch, + resolveMeshcoreNodePubKey, +} from './meshcoreHookPreamble'; const PEER_PUBKEY_HEX = '0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20'; @@ -54,4 +57,58 @@ describe('resolveMeshcoreNodePubKey', () => { await expect(resolveMeshcoreNodePubKey(PEER_NODE_ID, new Map())).resolves.toEqual(PEER_PUBKEY); expect(window.electronAPI.db.getMeshcoreContactById).toHaveBeenCalledWith(PEER_NODE_ID); }); + + it('ignores a map key that does not hash to nodeId and loads from SQLite', async () => { + const mismatched = new Uint8Array(32); + mismatched.fill(0xab); + expect(pubkeyToNodeId(mismatched)).not.toBe(PEER_NODE_ID); + vi.mocked(window.electronAPI.db.getMeshcoreContactById).mockResolvedValue({ + public_key: PEER_PUBKEY_HEX, + } as { node_id: number; public_key: string; on_radio: number }); + + const map = new Map([[PEER_NODE_ID, mismatched]]); + await expect(resolveMeshcoreNodePubKey(PEER_NODE_ID, map)).resolves.toEqual(PEER_PUBKEY); + expect(window.electronAPI.db.getMeshcoreContactById).toHaveBeenCalledWith(PEER_NODE_ID); + }); +}); + +describe('reloadMeshcorePubKeyIfNodeIdMismatch', () => { + beforeEach(() => { + vi.mocked(window.electronAPI.db.getMeshcoreContactById).mockResolvedValue(null); + clearMeshcorePubKeyRegistry(); + }); + + it('returns the same key when it already matches nodeId', async () => { + const map = new Map([[PEER_NODE_ID, PEER_PUBKEY]]); + await expect( + reloadMeshcorePubKeyIfNodeIdMismatch(PEER_NODE_ID, PEER_PUBKEY, map, 'test'), + ).resolves.toEqual(PEER_PUBKEY); + }); + + it('replaces a mismatched map key from SQLite', async () => { + const mismatched = new Uint8Array(32); + mismatched.fill(0xcd); + const map = new Map([[PEER_NODE_ID, mismatched]]); + vi.mocked(window.electronAPI.db.getMeshcoreContactById).mockResolvedValue({ + public_key: PEER_PUBKEY_HEX, + } as { node_id: number; public_key: string; on_radio: number }); + + const resolved = await reloadMeshcorePubKeyIfNodeIdMismatch( + PEER_NODE_ID, + mismatched, + map, + 'test', + ); + expect(resolved).toEqual(PEER_PUBKEY); + expect(map.get(PEER_NODE_ID)).toEqual(PEER_PUBKEY); + }); + + it('throws when no matching key can be loaded', async () => { + const mismatched = new Uint8Array(32); + mismatched.fill(0xee); + const map = new Map([[PEER_NODE_ID, mismatched]]); + await expect( + reloadMeshcorePubKeyIfNodeIdMismatch(PEER_NODE_ID, mismatched, map, 'test'), + ).rejects.toThrow(/Room key out of sync/); + }); }); diff --git a/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts b/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts index b78083641..cd4b4b916 100644 --- a/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts +++ b/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts @@ -865,18 +865,22 @@ export function meshcoreFullPubKeyBytesFromContactDbHex(raw: string): Uint8Array * Resolve a 32-byte MeshCore contact pubkey for export/share/DM paths. * Order: runtime map → global registry → live store slice → SQLite contact row. */ +function meshcorePubKeyMatchesNodeId(pubKey: Uint8Array, nodeId: number): boolean { + return pubKey.length === 32 && pubkeyToNodeId(pubKey) === nodeId; +} + export async function resolveMeshcoreNodePubKey( nodeId: number, pubKeyByNodeId: ReadonlyMap, storePublicKey?: Uint8Array, ): Promise { const fromMap = pubKeyByNodeId.get(nodeId); - if (fromMap?.length === 32) return fromMap; + if (fromMap && meshcorePubKeyMatchesNodeId(fromMap, nodeId)) return fromMap; const fromRegistry = getMeshcorePubKey(nodeId); - if (fromRegistry?.length === 32) return fromRegistry; + if (fromRegistry && meshcorePubKeyMatchesNodeId(fromRegistry, nodeId)) return fromRegistry; - if (storePublicKey?.length === 32) return storePublicKey; + if (storePublicKey && meshcorePubKeyMatchesNodeId(storePublicKey, nodeId)) return storePublicKey; try { const contact = (await window.electronAPI.db.getMeshcoreContactById(nodeId)) as @@ -893,6 +897,29 @@ export async function resolveMeshcoreNodePubKey( return null; } +/** + * When the runtime map's 32-byte key does not hash to `nodeId`, reload from registry/DB + * and update the map. Throws if the key is still mismatched. + */ +export async function reloadMeshcorePubKeyIfNodeIdMismatch( + nodeId: number, + pubKey: Uint8Array, + pubKeyMap: Map, + logTag: string, +): Promise { + if (meshcorePubKeyMatchesNodeId(pubKey, nodeId)) return pubKey; + try { + const resolved = await resolveMeshcoreNodePubKey(nodeId, pubKeyMap); + if (resolved && meshcorePubKeyMatchesNodeId(resolved, nodeId)) { + pubKeyMap.set(nodeId, resolved); + return resolved; + } + } catch (e: unknown) { + console.warn(`[${logTag}] pubkey reload failed ` + errLikeToLogString(e)); + } + throw new Error('Room key out of sync — reconnect or refresh contacts.'); +} + /** Pre-seed global pubkey registry from SQLite before PacketRouter subscribe (DM prefix decode). */ export function registerMeshcorePubKeysFromContactDbRows( rows: readonly Pick[], diff --git a/src/renderer/lib/meshcoreRoomLoginPathSync.test.ts b/src/renderer/lib/meshcoreRoomLoginPathSync.test.ts index 93931cb71..59585cf1c 100644 --- a/src/renderer/lib/meshcoreRoomLoginPathSync.test.ts +++ b/src/renderer/lib/meshcoreRoomLoginPathSync.test.ts @@ -145,4 +145,56 @@ describe('resetMeshcoreRoomCompanionSyncSinceForCatchUp', () => { expect(removeContact).toHaveBeenCalledWith(pubKey); expect(addOrUpdateContact).toHaveBeenCalled(); }); + + it('retries add after remove if the first add fails', async () => { + const pubKey = makePubKey(0x41); + const nodeId = pubkeyToNodeId(pubKey); + const contact: MeshCoreContactRaw = { + publicKey: pubKey, + type: 3, + flags: 0, + outPathLen: 0, + outPath: new Uint8Array(64), + advName: 'Retry Room', + lastAdvert: 1, + advLat: 0, + advLon: 0, + }; + const removeContact = vi.fn().mockResolvedValue(undefined); + const addOrUpdateContact = vi + .fn() + .mockRejectedValueOnce(new Error('timeout')) + .mockResolvedValueOnce(undefined); + const conn = { + getContacts: vi.fn().mockResolvedValue([contact]), + setContactPath: vi.fn(), + removeContact, + addOrUpdateContact, + }; + const { resetMeshcoreRoomCompanionSyncSinceForCatchUp } = + await import('./meshcoreRoomLoginPathSync'); + await expect(resetMeshcoreRoomCompanionSyncSinceForCatchUp(conn, nodeId, pubKey)).resolves.toBe( + 'reset', + ); + expect(removeContact).toHaveBeenCalledTimes(1); + expect(addOrUpdateContact).toHaveBeenCalledTimes(2); + }); + + it('skips catch-up reset when the login abort signal is already aborted', async () => { + const pubKey = makePubKey(0x42); + const conn = { + getContacts: vi.fn(), + setContactPath: vi.fn(), + removeContact: vi.fn(), + addOrUpdateContact: vi.fn(), + }; + const { resetMeshcoreRoomCompanionSyncSinceForCatchUp } = + await import('./meshcoreRoomLoginPathSync'); + const signal = AbortSignal.abort(); + await expect( + resetMeshcoreRoomCompanionSyncSinceForCatchUp(conn, pubkeyToNodeId(pubKey), pubKey, signal), + ).resolves.toBe('skipped'); + expect(conn.getContacts).not.toHaveBeenCalled(); + expect(conn.removeContact).not.toHaveBeenCalled(); + }); }); diff --git a/src/renderer/lib/meshcoreRoomLoginPathSync.ts b/src/renderer/lib/meshcoreRoomLoginPathSync.ts index d01e1fad1..566e79981 100644 --- a/src/renderer/lib/meshcoreRoomLoginPathSync.ts +++ b/src/renderer/lib/meshcoreRoomLoginPathSync.ts @@ -38,8 +38,12 @@ export async function resetMeshcoreRoomCompanionSyncSinceForCatchUp( conn: MeshcoreRoomLoginPathSyncConn, nodeId: number, pubKey: Uint8Array, + signal?: AbortSignal, ): Promise<'reset' | 'skipped' | 'failed'> { - if (!conn.removeContact || !conn.addOrUpdateContact) return 'skipped'; + const removeContact = conn.removeContact; + const addOrUpdate = conn.addOrUpdateContact; + if (!removeContact || !addOrUpdate) return 'skipped'; + if (signal?.aborted) return 'skipped'; let contact: MeshCoreContactRaw | undefined; try { const contacts = await withTimeout( @@ -53,34 +57,57 @@ export async function resetMeshcoreRoomCompanionSyncSinceForCatchUp( return 'failed'; } if (!contact) return 'skipped'; + if (signal?.aborted) return 'skipped'; + const existing = contact; try { await withTimeout( - conn.removeContact(pubKey), + removeContact(pubKey), MESHCORE_ROOM_LOGIN_PATH_SYNC_TIMEOUT_MS, 'meshcoreRoomSyncSinceResetRemove', ); - await withTimeout( - conn.addOrUpdateContact( + } catch (e: unknown) { + console.warn( + '[meshcoreRoomLoginPathSync] sync_since catch-up remove failed ' + + (e instanceof Error ? e.message : String(e)), + ); + return 'failed'; + } + const addContact = (): Promise => + withTimeout( + addOrUpdate( pubKey, - contact.type, - contact.flags, - contact.outPathLen ?? 0, - contact.outPath instanceof Uint8Array ? contact.outPath : new Uint8Array(64), - contact.advName, - contact.lastAdvert, - contact.advLat, - contact.advLon, + existing.type, + existing.flags, + existing.outPathLen ?? 0, + existing.outPath instanceof Uint8Array ? existing.outPath : new Uint8Array(64), + existing.advName, + existing.lastAdvert, + existing.advLat, + existing.advLon, ), MESHCORE_ROOM_LOGIN_PATH_SYNC_TIMEOUT_MS, 'meshcoreRoomSyncSinceResetAdd', ); + // Once remove succeeds, always try to restore the contact (retry once). Do not abort + // between remove and add — that would drop the room from the companion table. + try { + await addContact(); return 'reset'; } catch (e: unknown) { console.warn( - '[meshcoreRoomLoginPathSync] sync_since catch-up reset failed ' + + '[meshcoreRoomLoginPathSync] sync_since catch-up add failed, retrying restore ' + (e instanceof Error ? e.message : String(e)), ); - return 'failed'; + try { + await addContact(); + return 'reset'; + } catch (e2: unknown) { + console.warn( + '[meshcoreRoomLoginPathSync] sync_since catch-up restore failed; contact may be missing ' + + (e2 instanceof Error ? e2.message : String(e2)), + ); + return 'failed'; + } } } diff --git a/src/renderer/lib/meshcoreRoomLoginQueue.spacing.test.ts b/src/renderer/lib/meshcoreRoomLoginQueue.spacing.test.ts new file mode 100644 index 000000000..03bf764db --- /dev/null +++ b/src/renderer/lib/meshcoreRoomLoginQueue.spacing.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + dequeueMeshcoreRoomLogin, + enqueueMeshcoreRoomLogin, + resetMeshcoreRoomLoginQueue, +} from './meshcoreRoomLoginQueue'; +import { MESHCORE_ROOM_LOGIN_ABORT_MESSAGE } from './meshcoreRoomLoginRpc'; + +describe('meshcoreRoomLoginQueue TX spacing', () => { + afterEach(() => { + vi.useRealTimers(); + resetMeshcoreRoomLoginQueue(); + }); + + it('dequeue during TX spacing wait aborts without waiting the full interval', async () => { + vi.useFakeTimers(); + const first = enqueueMeshcoreRoomLogin(1, () => Promise.resolve()); + await first; + + const ranSecond = vi.fn(() => Promise.resolve()); + const second = enqueueMeshcoreRoomLogin(2, ranSecond); + dequeueMeshcoreRoomLogin(2); + await vi.advanceTimersByTimeAsync(100); + await expect(second).rejects.toMatchObject({ + message: MESHCORE_ROOM_LOGIN_ABORT_MESSAGE, + name: 'AbortError', + }); + expect(ranSecond).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/lib/meshcoreRoomLoginQueue.ts b/src/renderer/lib/meshcoreRoomLoginQueue.ts index 59c7ce131..06601ca41 100644 --- a/src/renderer/lib/meshcoreRoomLoginQueue.ts +++ b/src/renderer/lib/meshcoreRoomLoginQueue.ts @@ -1,5 +1,8 @@ import { MESHCORE_ROOM_LOGIN_ABORT_MESSAGE } from './meshcoreRoomLoginRpc'; -import { MESHCORE_ROOM_SYNC_MIN_MESH_TX_SPACING_MS } from './timeConstants'; +import { + MESHCORE_ROOM_LOGIN_QUEUE_SKIP_POLL_MS, + MESHCORE_ROOM_SYNC_MIN_MESH_TX_SPACING_MS, +} from './timeConstants'; /** One radio login at a time; many rooms can be queued. */ let chain: Promise = Promise.resolve(); @@ -23,6 +26,17 @@ function sleepMs(ms: number): Promise { }); } +/** Wait up to `ms`, returning early when this room is cancelled (skipped). */ +async function sleepMsUnlessSkipped(ms: number, nodeId: number): Promise { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + if (skippedNodeIds.has(nodeId)) return; + const slice = Math.min(MESHCORE_ROOM_LOGIN_QUEUE_SKIP_POLL_MS, deadline - Date.now()); + if (slice <= 0) return; + await sleepMs(slice); + } +} + export function getMeshcoreRoomLoginQueueSnapshot(): { activeNodeId: number | null; pendingNodeIds: number[]; @@ -73,7 +87,7 @@ export function enqueueMeshcoreRoomLogin(nodeId: number, run: () => Promise 0) { - await sleepMs(waitMs); + await sleepMsUnlessSkipped(waitMs, nodeId); } if (skippedNodeIds.has(nodeId)) { skippedNodeIds.delete(nodeId); diff --git a/src/renderer/lib/meshcoreRoomSession.test.ts b/src/renderer/lib/meshcoreRoomSession.test.ts index 2345d4780..ddf70def9 100644 --- a/src/renderer/lib/meshcoreRoomSession.test.ts +++ b/src/renderer/lib/meshcoreRoomSession.test.ts @@ -4,6 +4,7 @@ import { MESHCORE_ROOM_DEFAULT_GUEST_PASSWORD, MESHCORE_ROOM_LOGIN_ABORT_MESSAGE, MESHCORE_ROOM_LOGIN_NO_ROUTE_MESSAGE, + MESHCORE_ROOM_PERM_READ_ONLY, meshcoreAbortablePromise, meshcoreApplyRoomSession, meshcoreBeginRoomLoginOperation, @@ -199,6 +200,24 @@ describe('meshcoreRoomSession', () => { expect(meshcoreRoomCanPost(42)).toBe(true); }); + it('login maps permissions=1 (PERM_ACL_READ_ONLY) to read-only', async () => { + meshcoreClearAllRoomSessions(); + mockRunMeshcoreRoomLogin.mockResolvedValue({ permissions: MESHCORE_ROOM_PERM_READ_ONLY }); + const conn = { + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + sendToRadioFrame: vi.fn(), + }; + const pubKey = new Uint8Array(32); + await meshcoreRoomLogin(conn, 42, pubKey, 'hello', { + guestPassword: 'hello', + adminPassword: '', + }); + expect(meshcoreGetRoomSession(42)?.role).toBe('readonly'); + expect(meshcoreRoomCanPost(42)).toBe(false); + }); + it('skips relogin when already logged in without forceRelogin', async () => { meshcoreClearAllRoomSessions(); mockRunMeshcoreRoomLogin.mockResolvedValue({ permissions: 2 }); @@ -319,6 +338,34 @@ describe('meshcoreRoomSession', () => { expect(meshcoreIsRoomLoggedIn(42)).toBe(true); }); + it('aborts during retry backoff without waiting for the delay', async () => { + vi.useFakeTimers(); + meshcoreClearAllRoomSessions(); + mockRunMeshcoreRoomLogin.mockRejectedValue(new Error('timeout')); + const conn = { + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + sendToRadioFrame: vi.fn(), + }; + const pubKey = new Uint8Array(32); + const loginPromise = meshcoreRoomLogin(conn, 42, pubKey, 'hello', {}); + const settled = loginPromise.then( + () => ({ ok: true as const }), + (err: unknown) => ({ ok: false as const, err }), + ); + await Promise.resolve(); + await Promise.resolve(); + meshcoreCancelRoomLogin(42); + await vi.advanceTimersByTimeAsync(0); + const result = await settled; + expect(result.ok).toBe(false); + if (!result.ok) { + expect(meshcoreIsRoomLoginAbortError(result.err)).toBe(true); + } + expect(mockRunMeshcoreRoomLogin).toHaveBeenCalledTimes(1); + }); + it('tryRelogin reuses stored guest password before posting', async () => { meshcoreClearAllRoomSessions(); mockRunMeshcoreRoomLogin.mockResolvedValue({ permissions: 2 }); @@ -342,6 +389,29 @@ describe('meshcoreRoomSession', () => { }); }); + it('tryRelogin rethrows abort instead of treating it as a failed relogin', async () => { + meshcoreClearAllRoomSessions(); + mockRunMeshcoreRoomLogin.mockReturnValue(new Promise(() => undefined)); + const conn = { + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + sendToRadioFrame: vi.fn(), + }; + const pubKey = new Uint8Array(32); + meshcoreApplyRoomSession(42, { + guestPassword: 'hello', + adminPassword: '', + role: 'readwrite', + }); + const relogin = meshcoreRoomTryRelogin(conn, 42, pubKey, 'post'); + await vi.waitFor(() => { + expect(mockRunMeshcoreRoomLogin).toHaveBeenCalled(); + }); + meshcoreCancelRoomLogin(42); + await expect(relogin).rejects.toSatisfy((err: unknown) => meshcoreIsRoomLoginAbortError(err)); + }); + it('tryRelogin admin uses facade admin when session only has guest', async () => { meshcoreClearAllRoomSessions(); const { setMeshcoreRoomCredential } = await import('./meshcoreRoomCredentialStorage'); diff --git a/src/renderer/lib/meshcoreRoomSession.ts b/src/renderer/lib/meshcoreRoomSession.ts index ae1f40dc3..8b56acebd 100644 --- a/src/renderer/lib/meshcoreRoomSession.ts +++ b/src/renderer/lib/meshcoreRoomSession.ts @@ -42,6 +42,7 @@ export type MeshcoreRoomLoginConn = MeshcoreRadioConnection; /** Firmware PERM_ACL_ROLE_MASK values (CommonCLI / room server ACL). */ export const MESHCORE_ROOM_PERM_GUEST = 0; +export const MESHCORE_ROOM_PERM_READ_ONLY = 1; export const MESHCORE_ROOM_PERM_READ_WRITE = 2; export const MESHCORE_ROOM_PERM_ADMIN = 3; @@ -209,11 +210,16 @@ export function meshcoreClearRoomSession(nodeId: number): void { function roleFromPermissionsByte(permissions: number): MeshcoreRoomRole { const roleBits = permissions & 0x03; - if (roleBits === MESHCORE_ROOM_PERM_ADMIN) return 'admin'; - if (roleBits === MESHCORE_ROOM_PERM_READ_WRITE) return 'readwrite'; - if (roleBits === MESHCORE_ROOM_PERM_GUEST) return 'readonly'; - // PERM_ACL_READ_ONLY (1) and unknown → read-only UI. - return 'readonly'; + switch (roleBits) { + case MESHCORE_ROOM_PERM_ADMIN: + return 'admin'; + case MESHCORE_ROOM_PERM_READ_WRITE: + return 'readwrite'; + case MESHCORE_ROOM_PERM_GUEST: + case MESHCORE_ROOM_PERM_READ_ONLY: + default: + return 'readonly'; + } } /** @@ -383,11 +389,14 @@ export async function meshcoreRoomLogin( for (let attempt = 1; attempt <= MESHCORE_ROOM_LOGIN_MAX_ATTEMPTS; attempt++) { throwIfRoomLoginAborted(signal); try { - const response = await runMeshcoreRoomLogin(conn, pubKey, password, { - hopsAway: opts?.hopsAway, - companionTransport: opts?.companionTransport, + const response = await meshcoreAbortablePromise( + runMeshcoreRoomLogin(conn, pubKey, password, { + hopsAway: opts?.hopsAway, + companionTransport: opts?.companionTransport, + signal, + }), signal, - }); + ); throwIfRoomLoginAborted(signal); const role = resolveMeshcoreRoomLoginRole( response, @@ -413,7 +422,7 @@ export async function meshcoreRoomLogin( `[meshcoreRoomSession] room login attempt ${attempt}/${MESHCORE_ROOM_LOGIN_MAX_ATTEMPTS} failed ${errMsg}`, ); throwIfRoomLoginAborted(signal); - await sleepMs(MESHCORE_ROOM_LOGIN_RETRY_DELAY_MS); + await meshcoreAbortablePromise(sleepMs(MESHCORE_ROOM_LOGIN_RETRY_DELAY_MS), signal); } else { console.warn('[meshcoreRoomSession] room login failed ' + errMsg); } @@ -481,17 +490,18 @@ export async function meshcoreRoomTryRelogin( : session.adminPassword; const password = mode === 'admin' ? adminPassword : session.guestPassword; if (!password.trim()) return false; - const ok = await meshcoreRoomLogin(conn, nodeId, pubKey, password, { - adminPassword: adminPassword || session.adminPassword, - guestPassword: session.guestPassword, - hopsAway: opts?.hopsAway, - companionTransport: opts?.companionTransport, - forceRelogin: true, - }).then( - () => true, - () => false, - ); - if (!ok) return false; + try { + await meshcoreRoomLogin(conn, nodeId, pubKey, password, { + adminPassword: adminPassword || session.adminPassword, + guestPassword: session.guestPassword, + hopsAway: opts?.hopsAway, + companionTransport: opts?.companionTransport, + forceRelogin: true, + }); + } catch (e: unknown) { + if (meshcoreIsRoomLoginAbortError(e)) throw e; + return false; + } const roleOk = mode === 'admin' ? meshcoreRoomCanAdmin(nodeId) : meshcoreRoomCanPost(nodeId); return roleOk; } diff --git a/src/renderer/lib/timeConstants.ts b/src/renderer/lib/timeConstants.ts index bd6c77966..137e02c32 100644 --- a/src/renderer/lib/timeConstants.ts +++ b/src/renderer/lib/timeConstants.ts @@ -209,6 +209,9 @@ export const RF_SERIAL_OPEN_RETRY_DELAY_MS = 2_000; /** Minimum spacing between mesh TX operations used by room sync (login counts as TX). */ export const MESHCORE_ROOM_SYNC_MIN_MESH_TX_SPACING_MS = 60_000; +/** Poll while waiting for mesh TX spacing so Cancel can skip the remainder. */ +export const MESHCORE_ROOM_LOGIN_QUEUE_SKIP_POLL_MS = 50; + /** Minimum auto-sync interval per room (minutes). */ export const MESHCORE_ROOM_SYNC_MIN_INTERVAL_MINUTES = 60; diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index 29e98ec2d..fa207b0bc 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -64,7 +64,6 @@ import { MESHCORE_TRACE_TIMEOUT_MS, meshcoreContactRawFromDevice, meshcoreDmAckKeyU32, - meshcoreFullPubKeyBytesFromContactDbHex, meshcoreMessageDedupeKey, meshcorePendingDmAckMapKeys, meshcoreTraceRouteRejectReason, @@ -73,6 +72,7 @@ import { type PendingDmAckEntry, persistMeshcoreMessageSenderRepairs, registerMeshcorePubKeysFromContactDbRows, + reloadMeshcorePubKeyIfNodeIdMismatch, resolveMeshcoreNodePubKey, retryRadioRemoveDeletedContacts, serializeErrorLike, @@ -6155,6 +6155,8 @@ export function useMeshcoreRuntime() { rememberPassword?: boolean; forceRelogin?: boolean; abortIfStale?: () => boolean; + /** Skip trace/prime during background auto-sync (short route-resolve budget). */ + schedulerFastPath?: boolean; }, ): Promise => { let pubKey = pubKeyMapRef.current.get(nodeId); @@ -6169,27 +6171,12 @@ export function useMeshcoreRuntime() { 'Room has no RF encryption key — wait for contact sync or reconnect radio.', ); } - if (pubkeyToNodeId(pubKey) !== nodeId) { - try { - const rows = - (await window.electronAPI.db.getMeshcoreContacts()) as MeshcoreContactDbRow[]; - const row = rows.find((r) => r.node_id === nodeId); - if (row) { - const bytes = meshcoreFullPubKeyBytesFromContactDbHex(row.public_key); - if (bytes && pubkeyToNodeId(bytes) === nodeId) { - pubKeyMapRef.current.set(nodeId, bytes); - pubKey = bytes; - } - } - } catch (e: unknown) { - console.warn( - '[useMeshcoreRuntime] loginRoom pubkey reload from DB failed ' + errLikeToLogString(e), - ); - } - if (pubkeyToNodeId(pubKey) !== nodeId) { - throw new Error('Room key out of sync — reconnect or refresh contacts.'); - } - } + pubKey = await reloadMeshcorePubKeyIfNodeIdMismatch( + nodeId, + pubKey, + pubKeyMapRef.current, + 'useMeshcoreRuntime loginRoom', + ); const conn = connRef.current; if (!conn) { throw new Error(MESHCORE_ERR_NOT_CONNECTED); @@ -6220,7 +6207,9 @@ export function useMeshcoreRuntime() { } // Route prime can take 10s+ — do not hold repeaterRemoteRpc (SendLogin) mutex during flood/path wait. const storedPath = await meshcoreAbortablePromise( - resolveRoomLoginStoredPath(nodeId, hopsAway, pubKey), + resolveRoomLoginStoredPath(nodeId, hopsAway, pubKey, { + schedulerFastPath: opts?.schedulerFastPath, + }), loginAbortSignal, ); meshcoreThrowIfRoomLoginAborted(loginAbortSignal); @@ -6248,10 +6237,12 @@ export function useMeshcoreRuntime() { serializeMeshcoreUserMessage( pathSync.reason === 'no_path' ? MESHCORE_ROOM_LOGIN_NO_ROUTE_MESSAGE - : { - key: 'meshcore.errors.roomLogin.pathSyncFailedDetail', - params: { detail: pathSync.error ? ` (${pathSync.error})` : '' }, - }, + : pathSync.error + ? { + key: 'meshcore.errors.roomLogin.pathSyncFailedDetail', + params: { detail: ` (${pathSync.error})` }, + } + : MESHCORE_ROOM_LOGIN_PATH_SYNC_FAILED_MESSAGE, ), ); } @@ -6261,7 +6252,12 @@ export function useMeshcoreRuntime() { // No local posts yet → zero companion sync_since so login requests ring-buffer catch-up. if (getMeshcoreRoomLastPostAt(nodeId) == null) { await meshcoreAbortablePromise( - resetMeshcoreRoomCompanionSyncSinceForCatchUp(activeConn, nodeId, pubKey), + resetMeshcoreRoomCompanionSyncSinceForCatchUp( + activeConn, + nodeId, + pubKey, + loginAbortSignal, + ), loginAbortSignal, ); } @@ -6351,26 +6347,12 @@ export function useMeshcoreRuntime() { if (meshcoreIsSyntheticPlaceholderPubKeyHex(pubKeyHex)) { throw new Error('Room has no RF encryption key — wait for contact sync or reconnect radio.'); } - if (pubkeyToNodeId(pubKey) !== nodeId) { - try { - const rows = (await window.electronAPI.db.getMeshcoreContacts()) as MeshcoreContactDbRow[]; - const row = rows.find((r) => r.node_id === nodeId); - if (row) { - const bytes = meshcoreFullPubKeyBytesFromContactDbHex(row.public_key); - if (bytes && pubkeyToNodeId(bytes) === nodeId) { - pubKeyMapRef.current.set(nodeId, bytes); - pubKey = bytes; - } - } - } catch (e: unknown) { - console.warn( - '[useMeshcoreRuntime] leaveRoom pubkey reload from DB failed ' + errLikeToLogString(e), - ); - } - if (pubkeyToNodeId(pubKey) !== nodeId) { - throw new Error('Room key out of sync — reconnect or refresh contacts.'); - } - } + pubKey = await reloadMeshcorePubKeyIfNodeIdMismatch( + nodeId, + pubKey, + pubKeyMapRef.current, + 'useMeshcoreRuntime leaveRoom', + ); const conn = connRef.current; if (!conn) { throw new Error(MESHCORE_ERR_NOT_CONNECTED); @@ -6487,41 +6469,12 @@ export function useMeshcoreRuntime() { try { const password = meshcoreRoomEffectiveGuestPassword(cred.guestPassword ?? ''); - const activeConn = connRef.current; - if (!activeConn) return; - const syncHops = resolveRoomLoginHopsForNode(target.nodeId); - const storedPath = await resolveRoomLoginStoredPath(target.nodeId, syncHops, pubKey, { + if (!connRef.current) return; + await loginRoom(target.nodeId, password, { + guestPassword: password, + adminPassword: cred.adminPassword ?? '', schedulerFastPath: true, }); - if (syncHops > 0 && (!storedPath || storedPath.length <= 1)) { - await touchMeshcoreRoomLastSyncAt(target.nodeId, Date.now()); - return; - } - const pathSync = await syncMeshcoreRoomContactPathBeforeLogin( - activeConn, - target.nodeId, - pubKey, - getIdentityNode(meshcoreIdentityIdRef.current, target.nodeId), - storedPath, - syncHops, - (fn) => repeaterRemoteRpcRef.current(fn), - ); - if (syncHops > 0 && !pathSync.synced) { - if (pathSync.reason === 'no_path') { - await touchMeshcoreRoomLastSyncAt(target.nodeId, Date.now()); - return; - } - throw new Error(MESHCORE_ROOM_LOGIN_PATH_SYNC_FAILED_MESSAGE); - } - await repeaterRemoteRpcRef.current(async () => { - const rpcConn = connRef.current; - if (!rpcConn) return; - await meshcoreRoomLogin(rpcConn, target.nodeId, pubKey, password, { - guestPassword: password, - adminPassword: cred.adminPassword ?? '', - hopsAway: syncHops, - }); - }); lastMeshcoreRoomSyncTxAtRef.current = Date.now(); await touchMeshcoreRoomLastSyncAt(target.nodeId, Date.now()); roomSyncSchedulerWarnedNodesRef.current.delete(target.nodeId); @@ -6546,7 +6499,7 @@ export function useMeshcoreRuntime() { console.warn(logLine); } } - }, [resolveRoomLoginHopsForNode, resolveRoomLoginStoredPath]); + }, [loginRoom]); const runRoomSyncSchedulerTick = useCallback(async (): Promise => { if (!connRef.current || (state.status !== 'configured' && state.status !== 'connected')) { @@ -6634,40 +6587,17 @@ export function useMeshcoreRuntime() { if (!pubKey) return; try { const password = meshcoreRoomEffectiveGuestPassword(cred.guestPassword ?? ''); - const activeConn = connRef.current; - if (!activeConn) return; - const syncHops = resolveRoomLoginHopsForNode(target.nodeId); - const storedPath = await resolveRoomLoginStoredPath(target.nodeId, syncHops, pubKey); - if (syncHops > 0 && (!storedPath || storedPath.length <= 1)) { - throw new Error(MESHCORE_ROOM_LOGIN_NO_ROUTE_MESSAGE); - } - const pathSync = await syncMeshcoreRoomContactPathBeforeLogin( - activeConn, - target.nodeId, - pubKey, - getIdentityNode(meshcoreIdentityIdRef.current, target.nodeId), - storedPath, - syncHops, - (fn) => repeaterRemoteRpcRef.current(fn), - ); - if (syncHops > 0 && !pathSync.synced) { - throw new Error(MESHCORE_ROOM_LOGIN_PATH_SYNC_FAILED_MESSAGE); - } - await repeaterRemoteRpcRef.current(async () => { - const rpcConn = connRef.current; - if (!rpcConn) return; - await meshcoreRoomLogin(rpcConn, target.nodeId, pubKey, password, { - guestPassword: password, - adminPassword: cred.adminPassword ?? '', - hopsAway: syncHops, - }); + if (!connRef.current) return; + await loginRoom(target.nodeId, password, { + guestPassword: password, + adminPassword: cred.adminPassword ?? '', }); lastMeshcoreRoomSyncTxAtRef.current = Date.now(); await touchMeshcoreRoomLastSyncAt(target.nodeId, Date.now()); } catch (e: unknown) { console.debug('[useMeshcoreRuntime] room reconnect sync failed ' + errLikeToLogString(e)); } - }, [resolveRoomLoginHopsForNode, resolveRoomLoginStoredPath]); + }, [loginRoom]); meshcoreRoomReconnectSyncRef.current = () => { triggerRoomAutoLoginRef.current(); From fb20d5b43692549258ebec8d898c4e998187268a Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sat, 15 Aug 2026 13:18:06 -0600 Subject: [PATCH 9/9] fix(meshcore): harden room login races, RF persist, and RMAP opt-out Keep cancelled queue jobs from reviving on retry, persist fresh RF adverts as inserts, and leave unrecognized interface modes' ignore_config_warnings flag intact. Blank readwrite guest relogin, trimmed-path hop inference, and translated sidebar auto-login errors follow the same review pass. --- docs/troubleshooting.md | 4 +- reticulum-sidecar/src/stack/config.rs | 64 +++++++++++++---- reticulum-sidecar/src/stack/live.rs | 29 +++++++- src/renderer/components/RadioPanel.test.tsx | 12 +++- src/renderer/components/RoomsPanel.test.tsx | 22 ++++++ src/renderer/components/RoomsPanel.tsx | 8 ++- ...meshcoreHookPreamble.resolvePubKey.test.ts | 14 ++++ .../hooks/meshcore/meshcoreHookPreamble.ts | 3 +- .../lib/meshcore/meshcoreRfRxRuntime.test.ts | 26 +++++++ .../lib/meshcore/meshcoreRfRxRuntime.ts | 24 +++---- .../lib/meshcoreRoomCredentialStorage.ts | 1 - .../lib/meshcoreRoomLoginPathSync.test.ts | 68 +++++++++++++++++++ src/renderer/lib/meshcoreRoomLoginPathSync.ts | 5 +- .../meshcoreRoomLoginQueue.spacing.test.ts | 4 ++ .../lib/meshcoreRoomLoginQueue.test.ts | 25 +++++++ src/renderer/lib/meshcoreRoomLoginQueue.ts | 46 +++++++------ src/renderer/lib/meshcoreRoomSession.test.ts | 23 +++++++ src/renderer/lib/meshcoreRoomSession.ts | 4 +- src/renderer/lib/meshcoreUtils.test.ts | 6 ++ src/renderer/lib/meshcoreUtils.ts | 18 +++-- src/renderer/runtime/useMeshcoreRuntime.ts | 9 ++- 21 files changed, 344 insertions(+), 71 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d44c21a4e..2d937034c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -921,12 +921,12 @@ The client deduplicates overlapping RF and MQTT hears within **5 minutes** (cros **No room history after login**: - Room servers keep a **short ring buffer** of recent posts and push anything newer than your companion’s `sync_since` watermark after LoginSuccess. mesh-client resets that watermark (remove+re-add contact) when this device has **no local last-post watermark** yet, then drains waiting messages after login. -- Posts older than the ring (or already past `sync_since`) will not appear. Enable **Auto-sync** on the Rooms tab to periodic re-login while connected so you stay current. +- Posts older than the ring (or already past `sync_since`) will not appear. Enable **Auto-sync** on the Rooms tab to periodically re-login while connected so you stay current. - mesh-client stores posts received while you are logged in on **this device**. Quitting the app or staying logged out for days means posts from that period will not appear later unless they were persisted locally. See the **Rooms** tab history note under Auto-sync. **pyMC / server console shows posts but Rooms tab does not (cross-client)**: -- The room **server log** (e.g. pyMC) lists everything the BBS stored. mesh-client and the official app only show posts **pushed to your radio while you are logged in** to that room (see above). Posts made before your login, or while you were logged out, will not appear until someone posts again after you re-login (or use **Auto-sync** to periodic re-login). +- The room **server log** (e.g. pyMC) lists everything the BBS stored. mesh-client and the official app only show posts **pushed to your radio while you are logged in** to that room (see above). Posts made before your login, or while you were logged out, will not appear until someone posts again after you re-login (or use **Auto-sync** to periodically re-login). - For a fair test: keep **both** clients logged into the **same room** while connected, then post from one side and confirm the other receives it within ~30 seconds on RF. - mesh-client sends outbound room posts as **`TXT_TYPE_PLAIN`**; inbound BBS pushes use **`TXT_TYPE_SIGNED_PLAIN`** (author prefix stripped in the Rooms UI). diff --git a/reticulum-sidecar/src/stack/config.rs b/reticulum-sidecar/src/stack/config.rs index f9686b45f..d50866774 100644 --- a/reticulum-sidecar/src/stack/config.rs +++ b/reticulum-sidecar/src/stack/config.rs @@ -791,21 +791,19 @@ fn mode_allows_discovery_without_opt_out(mode: &str) -> bool { /// Stamp or clear `ignore_config_warnings` so discoverable + Full/Roaming/Boundary /// keeps the configured mode (upstream RNS opt-out). Omitted mode does not write /// the flag (RNode type default is AP). Clear when discoverable is off or mode is -/// already AP/Gateway. +/// already AP/Gateway. Unrecognized non-empty mode is left untouched. pub(crate) fn reconcile_ignore_config_warnings(row: &mut InterfaceRow) { let discoverable = row.discoverable == Some(true); - let explicit_mode = row - .mode - .as_deref() - .map(str::trim) - .filter(|m| !m.is_empty()) - .and_then(|m| normalize_interface_mode(m).ok().flatten()); - - let should_opt_out = discoverable - && explicit_mode - .as_deref() - .is_some_and(|m| !mode_allows_discovery_without_opt_out(m)); + let raw_mode = row.mode.as_deref().map(str::trim).filter(|m| !m.is_empty()); + let Some(raw_mode) = raw_mode else { + row.ignore_config_warnings = None; + return; + }; + let Ok(Some(canonical)) = normalize_interface_mode(raw_mode) else { + return; + }; + let should_opt_out = discoverable && !mode_allows_discovery_without_opt_out(&canonical); if should_opt_out { row.ignore_config_warnings = Some(true); } else { @@ -3732,4 +3730,46 @@ longitude = -105.0 reconcile_ignore_config_warnings(&mut row); assert_eq!(row.ignore_config_warnings, Some(true)); } + + #[test] + fn reconcile_unrecognized_mode_preserves_existing_opt_out() { + let mut row = InterfaceRow { + id: "rnode".into(), + name: "LoRa".into(), + iface_type: "rnode".into(), + enabled: true, + status: "up".into(), + host: None, + port: None, + preset: None, + serial_port: Some("/dev/ttyUSB0".into()), + frequency: None, + bandwidth: None, + txpower: None, + spreading_factor: None, + coding_rate: None, + callsign: None, + id_interval: None, + mode: Some("not_a_real_mode".into()), + runtime_mode: None, + seed_addresses: Vec::new(), + discoverable: Some(true), + latitude: Some(40.0), + longitude: Some(-105.0), + height: None, + discovery_name: None, + announce_interval_min: None, + connectable: None, + reachable_on: None, + network_name: None, + passphrase: None, + flow_control: None, + ignore_config_warnings: Some(true), + tx_queue_used: None, + tx_queue_max: None, + extra_config: HashMap::new(), + }; + reconcile_ignore_config_warnings(&mut row); + assert_eq!(row.ignore_config_warnings, Some(true)); + } } diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index c9604e0ab..5283b9ac4 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -117,6 +117,15 @@ pub(crate) fn live_interface_tx_queue_fields( } } +/// Live `GetInterfaceStats.mode` is only meaningful while the interface is online. +pub(crate) fn live_interface_runtime_mode_if_online(online: bool, mode: &str) -> Option { + if online { + config::live_interface_runtime_mode(mode) + } else { + None + } +} + pub struct LiveBridge { config_dir: PathBuf, storage_dir: PathBuf, @@ -2502,7 +2511,9 @@ impl LiveBridge { callsign: None, id_interval: None, mode: None, - runtime_mode: config::live_interface_runtime_mode(&s.mode), + runtime_mode: live_interface_runtime_mode_if_online( + s.online, &s.mode, + ), seed_addresses: Vec::new(), discoverable: None, latitude: None, @@ -4152,7 +4163,7 @@ impl LiveBridge { callsign: None, id_interval: None, mode: None, - runtime_mode: config::live_interface_runtime_mode(&s.mode), + runtime_mode: live_interface_runtime_mode_if_online(s.online, &s.mode), seed_addresses: Vec::new(), discoverable: None, latitude: None, @@ -7088,7 +7099,7 @@ mod reply_field_tests { #[cfg(test)] mod live_tx_queue_fields_tests { - use super::live_interface_tx_queue_fields; + use super::{live_interface_runtime_mode_if_online, live_interface_tx_queue_fields}; #[test] fn online_stats_populate_used_and_max() { @@ -7110,4 +7121,16 @@ mod live_tx_queue_fields_tests { assert_eq!(live_interface_tx_queue_fields(true, 0, 0), (None, None)); assert_eq!(live_interface_tx_queue_fields(true, 10, 0), (None, None)); } + + #[test] + fn offline_exposes_no_runtime_mode() { + assert_eq!( + live_interface_runtime_mode_if_online(false, "AccessPoint"), + None + ); + assert_eq!( + live_interface_runtime_mode_if_online(true, "AccessPoint").as_deref(), + Some("access_point") + ); + } } diff --git a/src/renderer/components/RadioPanel.test.tsx b/src/renderer/components/RadioPanel.test.tsx index 31e8fcb40..876c7e9fb 100644 --- a/src/renderer/components/RadioPanel.test.tsx +++ b/src/renderer/components/RadioPanel.test.tsx @@ -115,7 +115,7 @@ describe('RadioPanel MeshCore Device User / Identity', () => { it('enables Apply and calls onSetOwner when MeshCore capabilities provide the handler', async () => { const user = userEvent.setup(); const onSetOwner = vi.fn().mockResolvedValue(undefined); - render( + const { container } = render( { ); await openDeviceUserSection(user); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); const nameInput = screen.getByLabelText('Name'); await user.clear(nameInput); @@ -147,20 +149,22 @@ describe('RadioPanel MeshCore Device User / Identity', () => { it('keeps Apply disabled when MeshCore capabilities are set but onSetOwner is missing', async () => { const user = userEvent.setup(); - render( + const { container } = render( , ); await openDeviceUserSection(user); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); expect(screen.getByRole('button', { name: 'Apply Device User / Identity' })).toBeDisabled(); }); it('prefills the MeshCore name field from deviceOwner', async () => { const user = userEvent.setup(); - render( + const { container } = render( { ); await openDeviceUserSection(user); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); expect(screen.getByLabelText('Name')).toHaveValue('TagName'); }); diff --git a/src/renderer/components/RoomsPanel.test.tsx b/src/renderer/components/RoomsPanel.test.tsx index 14924c35d..019767092 100644 --- a/src/renderer/components/RoomsPanel.test.tsx +++ b/src/renderer/components/RoomsPanel.test.tsx @@ -73,6 +73,9 @@ vi.mock('react-i18next', () => ({ const detail = typeof opts?.detail === 'string' ? opts.detail : ''; return `Could not program the route on your radio before login. Reconnect the device and try again.${detail}`; } + if (key === 'meshcore.errors.roomLogin.timedOut') { + return 'Room login timed out. The room may be out of range or not responding.'; + } if (key === 'roomsPanel.autoLoginFailed' && typeof opts?.error === 'string') { return `Auto-login failed: ${opts.error}`; } @@ -888,6 +891,25 @@ describe('RoomsPanel', () => { expect(marker.className).toContain('ring-red-500'); }); + it('translates serialized auto-login failure in the sidebar marker', () => { + const room = makeRoom(0x1010, 'Serialized Auto Fail Room'); + const nodes = new Map([[room.node_id, room]]); + const serialized = serializeMeshcoreUserMessage({ + key: 'meshcore.errors.roomLogin.timedOut', + }); + setMeshcoreRoomAutoLoginFailure(room.node_id, serialized); + + renderRoomsPanel(nodes); + + expect(screen.getByLabelText('roomsPanel.autoLoginFailedAria')).toBeInTheDocument(); + expect( + screen.getByTitle( + 'Auto-login failed: Room login timed out. The room may be out of range or not responding.', + ), + ).toBeInTheDocument(); + expect(screen.queryByText(serialized)).not.toBeInTheDocument(); + }); + it('clears auto-login failure when re-enabling auto-login on connect', async () => { const room = makeRoom(0x1023, 'Re-enable Auto Room'); const nodes = new Map([[room.node_id, room]]); diff --git a/src/renderer/components/RoomsPanel.tsx b/src/renderer/components/RoomsPanel.tsx index 56f3bc7aa..a2efbb50b 100644 --- a/src/renderer/components/RoomsPanel.tsx +++ b/src/renderer/components/RoomsPanel.tsx @@ -1484,6 +1484,8 @@ export default function RoomsPanel({ const isLoggingIn = isRoomLoginInProgress(room.node_id) && !isLogged; const isLeaving = leaveLoadingRoomIds.has(room.node_id); const autoLoginFailed = getMeshcoreRoomAutoLoginFailure(room.node_id); + const autoLoginFailedDisplay = + autoLoginFailed != null ? translateMeshcoreUserMessage(t, autoLoginFailed) : ''; const showAutoLoginFailed = Boolean(autoLoginFailed) && !isLogged && !isLoggingIn && !isLeaving; const marker = resolveMeshcoreRoomSidebarMarker({ @@ -1496,7 +1498,7 @@ export default function RoomsPanel({ : isLeaving ? t('roomsPanel.leaveRoomInProgress') : showAutoLoginFailed - ? t('roomsPanel.autoLoginFailed', { error: autoLoginFailed ?? '' }) + ? t('roomsPanel.autoLoginFailed', { error: autoLoginFailedDisplay }) : hasSaved ? t('roomsPanel.legendSavedTooltip') : t('roomsPanel.legendNotSavedTooltip'); @@ -1603,7 +1605,9 @@ export default function RoomsPanel({ aria-hidden={!showAutoLoginFailed} aria-label={ showAutoLoginFailed - ? t('roomsPanel.autoLoginFailedAria', { error: autoLoginFailed }) + ? t('roomsPanel.autoLoginFailedAria', { + error: autoLoginFailedDisplay, + }) : undefined } title={markerTitle} diff --git a/src/renderer/hooks/meshcore/meshcoreHookPreamble.resolvePubKey.test.ts b/src/renderer/hooks/meshcore/meshcoreHookPreamble.resolvePubKey.test.ts index 887ced6c5..99cd06ed7 100644 --- a/src/renderer/hooks/meshcore/meshcoreHookPreamble.resolvePubKey.test.ts +++ b/src/renderer/hooks/meshcore/meshcoreHookPreamble.resolvePubKey.test.ts @@ -58,6 +58,20 @@ describe('resolveMeshcoreNodePubKey', () => { expect(window.electronAPI.db.getMeshcoreContactById).toHaveBeenCalledWith(PEER_NODE_ID); }); + it('ignores a SQLite pubkey that does not hash to nodeId', async () => { + const mismatched = new Uint8Array(32); + mismatched.fill(0xab); + const mismatchedHex = Array.from(mismatched) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + expect(pubkeyToNodeId(mismatched)).not.toBe(PEER_NODE_ID); + vi.mocked(window.electronAPI.db.getMeshcoreContactById).mockResolvedValue({ + public_key: mismatchedHex, + } as { node_id: number; public_key: string; on_radio: number }); + + await expect(resolveMeshcoreNodePubKey(PEER_NODE_ID, new Map())).resolves.toBeNull(); + }); + it('ignores a map key that does not hash to nodeId and loads from SQLite', async () => { const mismatched = new Uint8Array(32); mismatched.fill(0xab); diff --git a/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts b/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts index cd4b4b916..a1308e1f0 100644 --- a/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts +++ b/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts @@ -886,7 +886,8 @@ export async function resolveMeshcoreNodePubKey( const contact = (await window.electronAPI.db.getMeshcoreContactById(nodeId)) as Pick | null | undefined; if (contact?.public_key) { - return meshcoreFullPubKeyBytesFromContactDbHex(contact.public_key); + const fromDb = meshcoreFullPubKeyBytesFromContactDbHex(contact.public_key); + if (fromDb && meshcorePubKeyMatchesNodeId(fromDb, nodeId)) return fromDb; } } catch (e: unknown) { console.warn( diff --git a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts index 004c70ee5..2ffb9f82e 100644 --- a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts +++ b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts @@ -283,6 +283,32 @@ describe('handleMeshcoreRfRx advert identity', () => { longName: name, hwModel: 'Room', }); + expect(window.electronAPI.db.saveMeshcoreContact).toHaveBeenCalledWith( + expect.objectContaining({ adv_name: name, contact_type: 3 }), + ); + }); + + it('persists a fresh non-tombstoned RF advert via saveMeshcoreContact', () => { + const publicKey = Uint8Array.from({ length: 32 }, (_, i) => (i + 7) & 0xff); + const nodeId = pubkeyToNodeId(publicKey); + const { deps } = makeDeps({ myNodeNumRef: ref(1) }); + vi.mocked(window.electronAPI.db.saveMeshcoreContact).mockResolvedValue(undefined); + vi.mocked(window.electronAPI.db.updateMeshcoreContactAdvert).mockResolvedValue(undefined); + + handleMeshcoreRfRx( + { + lastSnr: 8, + lastRssi: -30, + raw: buildFloodAdvertPacket({ publicKey, name: 'Alice', deviceRole: 1 }), + }, + deps, + ); + + expect(useNodeStore.getState().nodes[ID][nodeId].longName).toBe('Alice'); + expect(window.electronAPI.db.saveMeshcoreContact).toHaveBeenCalledWith( + expect.objectContaining({ adv_name: 'Alice', contact_type: 1, on_radio: 1 }), + ); + expect(window.electronAPI.db.updateMeshcoreContactAdvert).not.toHaveBeenCalled(); }); it('revives a locally deleted contact when a live RF advert is heard', () => { diff --git a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts index 9d7ffda61..2625635b5 100644 --- a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts +++ b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts @@ -355,6 +355,18 @@ function applyMeshcoreRfAdvertToStore( const hwModel = mergeHwModelOnContactUpdate(existing?.hwModel, incomingHw); const lastHeardAt = advert.timestampSec > 0 ? advert.timestampSec : Math.floor(now / 1000); + persistMeshcoreNodeInfoAfterAdvert( + identityId, + { + nodeId, + longName: name || undefined, + lastHeardAt, + publicKey, + hwModel, + }, + { contactType: advert.deviceRole }, + ); + upsertNode(identityId, { nodeId, ...(name ? { longName: name } : {}), @@ -374,18 +386,6 @@ function applyMeshcoreRfAdvertToStore( heardViaMqttOnly: false, viaMqtt: false, }); - - persistMeshcoreNodeInfoAfterAdvert( - identityId, - { - nodeId, - longName: name || undefined, - lastHeardAt, - publicKey, - hwModel, - }, - { contactType: advert.deviceRole }, - ); } function buildMeshcoreRfRawPacketEntry( diff --git a/src/renderer/lib/meshcoreRoomCredentialStorage.ts b/src/renderer/lib/meshcoreRoomCredentialStorage.ts index d972cf8a4..80653cce1 100644 --- a/src/renderer/lib/meshcoreRoomCredentialStorage.ts +++ b/src/renderer/lib/meshcoreRoomCredentialStorage.ts @@ -29,7 +29,6 @@ function parseCredentialValue(raw: unknown): MeshcoreRoomStoredCredential | unde const adminPassword = adminRaw.length > 0 ? adminRaw : undefined; // Persist when guestPassword key was saved (including empty) or admin is non-empty. if (!hasExplicitGuestPassword && !guestPassword && !adminPassword) return undefined; - if (!(hasExplicitGuestPassword || guestPassword || adminPassword)) return undefined; const out: MeshcoreRoomStoredCredential = {}; if (hasExplicitGuestPassword || guestPassword.length > 0) { out.guestPassword = guestPassword; diff --git a/src/renderer/lib/meshcoreRoomLoginPathSync.test.ts b/src/renderer/lib/meshcoreRoomLoginPathSync.test.ts index 59585cf1c..fb03f41a6 100644 --- a/src/renderer/lib/meshcoreRoomLoginPathSync.test.ts +++ b/src/renderer/lib/meshcoreRoomLoginPathSync.test.ts @@ -180,6 +180,74 @@ describe('resetMeshcoreRoomCompanionSyncSinceForCatchUp', () => { expect(addOrUpdateContact).toHaveBeenCalledTimes(2); }); + it('returns failed when both add attempts reject after remove', async () => { + const pubKey = makePubKey(0x44); + const nodeId = pubkeyToNodeId(pubKey); + const contact: MeshCoreContactRaw = { + publicKey: pubKey, + type: 3, + flags: 0, + outPathLen: 0, + outPath: new Uint8Array(64), + advName: 'Missing Room', + lastAdvert: 1, + advLat: 0, + advLon: 0, + }; + const removeContact = vi.fn().mockResolvedValue(undefined); + const addOrUpdateContact = vi.fn().mockRejectedValue(new Error('timeout')); + const conn = { + getContacts: vi.fn().mockResolvedValue([contact]), + setContactPath: vi.fn(), + removeContact, + addOrUpdateContact, + }; + const { resetMeshcoreRoomCompanionSyncSinceForCatchUp } = + await import('./meshcoreRoomLoginPathSync'); + await expect(resetMeshcoreRoomCompanionSyncSinceForCatchUp(conn, nodeId, pubKey)).resolves.toBe( + 'failed', + ); + expect(removeContact).toHaveBeenCalledTimes(1); + expect(addOrUpdateContact).toHaveBeenCalledTimes(2); + }); + + it('invokes catch-up remove/add with conn as this', async () => { + const pubKey = makePubKey(0x43); + const nodeId = pubkeyToNodeId(pubKey); + const contact: MeshCoreContactRaw = { + publicKey: pubKey, + type: 3, + flags: 0, + outPathLen: 0, + outPath: new Uint8Array(64), + advName: 'This Room', + lastAdvert: 1, + advLat: 0, + advLon: 0, + }; + const conn = { + commands: [] as string[], + getContacts() { + return Promise.resolve([contact]); + }, + setContactPath: vi.fn(), + removeContact(this: { commands: string[] }) { + this.commands.push('remove'); + return Promise.resolve(); + }, + addOrUpdateContact(this: { commands: string[] }) { + this.commands.push('add'); + return Promise.resolve(); + }, + }; + const { resetMeshcoreRoomCompanionSyncSinceForCatchUp } = + await import('./meshcoreRoomLoginPathSync'); + await expect(resetMeshcoreRoomCompanionSyncSinceForCatchUp(conn, nodeId, pubKey)).resolves.toBe( + 'reset', + ); + expect(conn.commands).toEqual(['remove', 'add']); + }); + it('skips catch-up reset when the login abort signal is already aborted', async () => { const pubKey = makePubKey(0x42); const conn = { diff --git a/src/renderer/lib/meshcoreRoomLoginPathSync.ts b/src/renderer/lib/meshcoreRoomLoginPathSync.ts index 566e79981..f7e0836a4 100644 --- a/src/renderer/lib/meshcoreRoomLoginPathSync.ts +++ b/src/renderer/lib/meshcoreRoomLoginPathSync.ts @@ -61,7 +61,7 @@ export async function resetMeshcoreRoomCompanionSyncSinceForCatchUp( const existing = contact; try { await withTimeout( - removeContact(pubKey), + removeContact.call(conn, pubKey), MESHCORE_ROOM_LOGIN_PATH_SYNC_TIMEOUT_MS, 'meshcoreRoomSyncSinceResetRemove', ); @@ -74,7 +74,8 @@ export async function resetMeshcoreRoomCompanionSyncSinceForCatchUp( } const addContact = (): Promise => withTimeout( - addOrUpdate( + addOrUpdate.call( + conn, pubKey, existing.type, existing.flags, diff --git a/src/renderer/lib/meshcoreRoomLoginQueue.spacing.test.ts b/src/renderer/lib/meshcoreRoomLoginQueue.spacing.test.ts index 03bf764db..d5c39c27a 100644 --- a/src/renderer/lib/meshcoreRoomLoginQueue.spacing.test.ts +++ b/src/renderer/lib/meshcoreRoomLoginQueue.spacing.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { dequeueMeshcoreRoomLogin, enqueueMeshcoreRoomLogin, + getMeshcoreRoomLoginQueueSnapshot, resetMeshcoreRoomLoginQueue, } from './meshcoreRoomLoginQueue'; import { MESHCORE_ROOM_LOGIN_ABORT_MESSAGE } from './meshcoreRoomLoginRpc'; @@ -20,6 +21,9 @@ describe('meshcoreRoomLoginQueue TX spacing', () => { const ranSecond = vi.fn(() => Promise.resolve()); const second = enqueueMeshcoreRoomLogin(2, ranSecond); + await Promise.resolve(); + await Promise.resolve(); + expect(getMeshcoreRoomLoginQueueSnapshot().activeNodeId).toBe(2); dequeueMeshcoreRoomLogin(2); await vi.advanceTimersByTimeAsync(100); await expect(second).rejects.toMatchObject({ diff --git a/src/renderer/lib/meshcoreRoomLoginQueue.test.ts b/src/renderer/lib/meshcoreRoomLoginQueue.test.ts index 5c52600a8..b7978d9bf 100644 --- a/src/renderer/lib/meshcoreRoomLoginQueue.test.ts +++ b/src/renderer/lib/meshcoreRoomLoginQueue.test.ts @@ -100,4 +100,29 @@ describe('meshcoreRoomLoginQueue', () => { await expect(third).resolves.toBeUndefined(); expect(ran).toHaveBeenCalledTimes(1); }); + + it('cancel of a pending job does not revive it when the same node is re-enqueued', async () => { + let releaseFirst: (() => void) | undefined; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const first = enqueueMeshcoreRoomLogin(1, async () => { + await firstGate; + }); + const ranCancelled = vi.fn(() => Promise.resolve()); + const cancelled = enqueueMeshcoreRoomLogin(2, ranCancelled); + dequeueMeshcoreRoomLogin(2); + const ranReplacement = vi.fn(() => Promise.resolve()); + const replacement = enqueueMeshcoreRoomLogin(2, ranReplacement); + + releaseFirst?.(); + await expect(first).resolves.toBeUndefined(); + await expect(cancelled).rejects.toMatchObject({ + message: MESHCORE_ROOM_LOGIN_ABORT_MESSAGE, + name: 'AbortError', + }); + await expect(replacement).resolves.toBeUndefined(); + expect(ranCancelled).not.toHaveBeenCalled(); + expect(ranReplacement).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/renderer/lib/meshcoreRoomLoginQueue.ts b/src/renderer/lib/meshcoreRoomLoginQueue.ts index 06601ca41..12813aa40 100644 --- a/src/renderer/lib/meshcoreRoomLoginQueue.ts +++ b/src/renderer/lib/meshcoreRoomLoginQueue.ts @@ -8,7 +8,8 @@ import { let chain: Promise = Promise.resolve(); let activeNodeId: number | null = null; const pendingNodeIds = new Set(); -const skippedNodeIds = new Set(); +/** Bumped on enqueue and dequeue so a cancelled pending job cannot be revived. */ +const jobGenerationByNode = new Map(); let lastMeshLoginTxAt = 0; type QueueListener = () => void; @@ -20,23 +21,35 @@ function notifyQueueChanged(): void { } } +function bumpJobGeneration(nodeId: number): number { + const next = (jobGenerationByNode.get(nodeId) ?? 0) + 1; + jobGenerationByNode.set(nodeId, next); + return next; +} + function sleepMs(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } -/** Wait up to `ms`, returning early when this room is cancelled (skipped). */ -async function sleepMsUnlessSkipped(ms: number, nodeId: number): Promise { +/** Wait up to `ms`, returning early when this job's generation is stale (cancelled). */ +async function sleepMsUnlessStale(ms: number, nodeId: number, gen: number): Promise { const deadline = Date.now() + ms; while (Date.now() < deadline) { - if (skippedNodeIds.has(nodeId)) return; + if (jobGenerationByNode.get(nodeId) !== gen) return; const slice = Math.min(MESHCORE_ROOM_LOGIN_QUEUE_SKIP_POLL_MS, deadline - Date.now()); if (slice <= 0) return; await sleepMs(slice); } } +function throwIfJobStale(nodeId: number, gen: number): void { + if (jobGenerationByNode.get(nodeId) !== gen) { + throw new DOMException(MESHCORE_ROOM_LOGIN_ABORT_MESSAGE, 'AbortError'); + } +} + export function getMeshcoreRoomLoginQueueSnapshot(): { activeNodeId: number | null; pendingNodeIds: number[]; @@ -67,19 +80,13 @@ export function meshcoreRoomLoginQueueSize(): number { * Failure point: prior jobs block the queue — callers should not assume immediate start. */ export function enqueueMeshcoreRoomLogin(nodeId: number, run: () => Promise): Promise { - // A new enqueue means the user wants another attempt. Clear a leftover skip from - // canceling an *active* login (dequeue marks skipped even when the job already started). - skippedNodeIds.delete(nodeId); + const gen = bumpJobGeneration(nodeId); pendingNodeIds.add(nodeId); notifyQueueChanged(); const job = chain.then(async () => { pendingNodeIds.delete(nodeId); - const wasSkipped = skippedNodeIds.has(nodeId); - if (wasSkipped) { - skippedNodeIds.delete(nodeId); - throw new DOMException(MESHCORE_ROOM_LOGIN_ABORT_MESSAGE, 'AbortError'); - } + throwIfJobStale(nodeId, gen); activeNodeId = nodeId; notifyQueueChanged(); const waitMs = @@ -87,12 +94,9 @@ export function enqueueMeshcoreRoomLogin(nodeId: number, run: () => Promise 0) { - await sleepMsUnlessSkipped(waitMs, nodeId); - } - if (skippedNodeIds.has(nodeId)) { - skippedNodeIds.delete(nodeId); - throw new DOMException(MESHCORE_ROOM_LOGIN_ABORT_MESSAGE, 'AbortError'); + await sleepMsUnlessStale(waitMs, nodeId, gen); } + throwIfJobStale(nodeId, gen); try { await run(); lastMeshLoginTxAt = Date.now(); @@ -111,9 +115,9 @@ export function enqueueMeshcoreRoomLogin(nodeId: number, run: () => Promise { }); }); + it('tryRelogin post sends blank guest password for a blank readwrite session', async () => { + meshcoreClearAllRoomSessions(); + mockRunMeshcoreRoomLogin.mockResolvedValue({ permissions: 2 }); + const conn = { + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + sendToRadioFrame: vi.fn(), + }; + const pubKey = new Uint8Array(32); + meshcoreApplyRoomSession(42, { + guestPassword: '', + adminPassword: '', + role: 'readwrite', + }); + const ok = await meshcoreRoomTryRelogin(conn, 42, pubKey, 'post'); + expect(ok).toBe(true); + expect(mockRunMeshcoreRoomLogin).toHaveBeenCalledWith(conn, pubKey, '', { + hopsAway: undefined, + signal: expect.any(AbortSignal), + }); + }); + it('tryRelogin rethrows abort instead of treating it as a failed relogin', async () => { meshcoreClearAllRoomSessions(); mockRunMeshcoreRoomLogin.mockReturnValue(new Promise(() => undefined)); diff --git a/src/renderer/lib/meshcoreRoomSession.ts b/src/renderer/lib/meshcoreRoomSession.ts index 8b56acebd..c35abd4b6 100644 --- a/src/renderer/lib/meshcoreRoomSession.ts +++ b/src/renderer/lib/meshcoreRoomSession.ts @@ -489,7 +489,9 @@ export async function meshcoreRoomTryRelogin( ? resolveRoomAdminPassword(nodeId, session.adminPassword) : session.adminPassword; const password = mode === 'admin' ? adminPassword : session.guestPassword; - if (!password.trim()) return false; + // Admin must have a non-empty password. Blank guest is a valid read-only / RW + // recovery SendLogin after reconnect (explicit remembered empty guest). + if (mode === 'admin' && !password.trim()) return false; try { await meshcoreRoomLogin(conn, nodeId, pubKey, password, { adminPassword: adminPassword || session.adminPassword, diff --git a/src/renderer/lib/meshcoreUtils.test.ts b/src/renderer/lib/meshcoreUtils.test.ts index 057f9aa0d..14d390258 100644 --- a/src/renderer/lib/meshcoreUtils.test.ts +++ b/src/renderer/lib/meshcoreUtils.test.ts @@ -515,6 +515,12 @@ describe('resolveMeshcoreRoomLoginHopsAway', () => { expect(resolveMeshcoreRoomLoginHopsAway({ hops_away: 1 }, undefined)).toBe(0); expect(resolveMeshcoreRoomLoginHopsAway({ hops_away: 3 }, new Uint8Array())).toBe(0); }); + + it('treats a padded direct route as 0-hop during room login', () => { + expect(resolveMeshcoreRoomLoginHopsAway({ hops_away: 3 }, new Uint8Array([0x42, 0, 0]))).toBe( + 0, + ); + }); }); describe('meshcoreMergeContactHopsAwayFromPrevious', () => { diff --git a/src/renderer/lib/meshcoreUtils.ts b/src/renderer/lib/meshcoreUtils.ts index 19f3b2814..18b31acea 100644 --- a/src/renderer/lib/meshcoreUtils.ts +++ b/src/renderer/lib/meshcoreUtils.ts @@ -572,20 +572,18 @@ export function resolveMeshcoreRoomLoginHopsAway( outPathBytes?: Uint8Array, ): number { const hops = node?.hops_away; - const hasMultiHopPath = Boolean(outPathBytes && outPathBytes.length > 1); + const inferred = + outPathBytes && outPathBytes.length > 0 + ? meshcoreInferHopsFromOutPath({ outPath: outPathBytes, outPathLen: -1 }) + : undefined; + const hasMultiHopPath = inferred != null && inferred > 0; if (typeof hops === 'number' && Number.isFinite(hops) && hops > 0 && hasMultiHopPath) { return Math.trunc(hops); } - if (outPathBytes && outPathBytes.length > 0) { - const inferred = meshcoreInferHopsFromOutPath({ outPath: outPathBytes, outPathLen: -1 }); - if (inferred != null && inferred > 0) { - return inferred; - } - if (outPathBytes.length > 1) { - return Math.max(1, outPathBytes.length - 1); - } + if (inferred != null && inferred > 0) { + return inferred; } - // Sticky multi-hop with no route bytes → treat as direct for login. + // Sticky multi-hop with no (trimmed) route bytes → treat as direct for login. if (typeof hops === 'number' && Number.isFinite(hops) && hops >= 0 && !hasMultiHopPath) { return 0; } diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index fa207b0bc..1ec9f8566 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -6321,7 +6321,7 @@ export function useMeshcoreRuntime() { 'loginRoom', ); } catch (e: unknown) { - if (errLikeToLogString(e).includes('loginRoom timed out')) { + if (!meshcoreIsRoomLoginAbortError(e)) { meshcoreCancelRoomLogin(nodeId); } throw e; @@ -6467,6 +6467,10 @@ export function useMeshcoreRuntime() { return; } + if (meshcoreIsRoomLoginQueued(target.nodeId)) { + return; + } + try { const password = meshcoreRoomEffectiveGuestPassword(cred.guestPassword ?? ''); if (!connRef.current) return; @@ -6585,6 +6589,9 @@ export function useMeshcoreRuntime() { if (!cred) return; const pubKey = pubKeyMapRef.current.get(target.nodeId); if (!pubKey) return; + if (meshcoreIsRoomLoginQueued(target.nodeId)) { + return; + } try { const password = meshcoreRoomEffectiveGuestPassword(cred.guestPassword ?? ''); if (!connRef.current) return;