From 61e3ae0471eafe80113e73cff23ac13e1c0851ad Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Tue, 4 Aug 2026 19:48:43 -0600 Subject: [PATCH 1/5] feat(reticulum): modem handshake then carrier during voice connect Replace post-DTMF auto-ringback with a short modem handshake and quiet carrier bed; cut to UK ringback as soon as the call reaches connecting. --- .../lib/reticulumVoiceCallTones.test.ts | 88 ++++++- src/renderer/lib/reticulumVoiceCallTones.ts | 238 ++++++++++++++++-- .../lib/reticulumVoiceSession.test.ts | 10 +- src/renderer/lib/reticulumVoiceSession.ts | 8 +- .../runtime/useReticulumRuntime.voice.test.ts | 1 + 5 files changed, 320 insertions(+), 25 deletions(-) diff --git a/src/renderer/lib/reticulumVoiceCallTones.test.ts b/src/renderer/lib/reticulumVoiceCallTones.test.ts index 7de318932..00110d9f4 100644 --- a/src/renderer/lib/reticulumVoiceCallTones.test.ts +++ b/src/renderer/lib/reticulumVoiceCallTones.test.ts @@ -7,9 +7,11 @@ import { DTMF_TO_RING_GAP_MS, dtmfKeysFromPeerHash, isOutgoingConnectToneSequenceActive, + MODEM_HANDSHAKE_MS, playVoiceBusyTone, playVoiceFailTone, playVoiceReorderTone, + promoteOutgoingConnectSequenceToRingback, resetVoiceCallTonesForTests, startOutgoingConnectToneSequence, startVoiceDialTone, @@ -19,19 +21,27 @@ import { describe('reticulumVoiceCallTones', () => { let oscillatorCount = 0; + let bufferSourceCount = 0; beforeEach(() => { oscillatorCount = 0; + bufferSourceCount = 0; resetVoiceCallTonesForTests(); localStorage.removeItem(CHAT_NOTIF_MUTED_STORAGE_KEY); class MockAudioContext { state: AudioContextState = 'running'; currentTime = 0; + sampleRate = 48000; destination = {} as AudioDestinationNode; createOscillator() { oscillatorCount += 1; return { - frequency: { value: 0 }, + type: 'sine', + frequency: { + value: 0, + setValueAtTime: () => undefined, + linearRampToValueAtTime: () => undefined, + }, connect: () => undefined, start: () => undefined, stop: () => undefined, @@ -44,11 +54,37 @@ describe('reticulumVoiceCallTones', () => { value: 0, setValueAtTime: () => undefined, exponentialRampToValueAtTime: () => undefined, + linearRampToValueAtTime: () => undefined, }, connect: () => undefined, disconnect: () => undefined, }; } + createBiquadFilter() { + return { + type: 'bandpass', + frequency: { setValueAtTime: () => undefined }, + Q: { setValueAtTime: () => undefined }, + connect: () => undefined, + disconnect: () => undefined, + }; + } + createBuffer(_channels: number, length: number) { + return { + getChannelData: () => new Float32Array(length), + }; + } + createBufferSource() { + bufferSourceCount += 1; + return { + buffer: null as AudioBuffer | null, + loop: false, + connect: () => undefined, + start: () => undefined, + stop: () => undefined, + disconnect: () => undefined, + }; + } resume() { return Promise.resolve(); } @@ -121,7 +157,7 @@ describe('reticulumVoiceCallTones', () => { expect(dtmfKeysFromPeerHash(h1)).not.toBe(dtmfKeysFromPeerHash(h2)); }); - it('connect sequence: dial 2s → DTMF → gap → ringback; stop cancels later ring', () => { + it('connect sequence: dial → DTMF → modem handshake → carrier; promote cuts to ringback', () => { vi.useFakeTimers(); const hash = 'a1b2' + 'c'.repeat(28); startOutgoingConnectToneSequence(hash); @@ -131,6 +167,7 @@ describe('reticulumVoiceCallTones', () => { expect(oscillatorCount).toBe(2); oscillatorCount = 0; + bufferSourceCount = 0; vi.advanceTimersByTime(1999); expect(oscillatorCount).toBe(0); expect(isOutgoingConnectToneSequenceActive()).toBe(true); @@ -141,26 +178,59 @@ describe('reticulumVoiceCallTones', () => { expect(isOutgoingConnectToneSequenceActive()).toBe(true); oscillatorCount = 0; - vi.advanceTimersByTime(DTMF_BURST_MS); - // Still in post-DTMF silence — ringback not yet. + bufferSourceCount = 0; + vi.advanceTimersByTime(DTMF_BURST_MS + DTMF_TO_RING_GAP_MS - 1); expect(oscillatorCount).toBe(0); + expect(bufferSourceCount).toBe(0); expect(isOutgoingConnectToneSequenceActive()).toBe(true); - vi.advanceTimersByTime(DTMF_TO_RING_GAP_MS - 1); + + vi.advanceTimersByTime(1); + // Handshake: answer osc + chirp osc + train buffer source. + expect(oscillatorCount).toBe(2); + expect(bufferSourceCount).toBe(1); + expect(isOutgoingConnectToneSequenceActive()).toBe(true); + + oscillatorCount = 0; + bufferSourceCount = 0; + vi.advanceTimersByTime(MODEM_HANDSHAKE_MS - 1); expect(oscillatorCount).toBe(0); + expect(bufferSourceCount).toBe(0); + vi.advanceTimersByTime(1); + // Carrier: sine osc + looping noise buffer (no ringback yet). + expect(oscillatorCount).toBe(1); + expect(bufferSourceCount).toBe(1); + expect(isOutgoingConnectToneSequenceActive()).toBe(true); + + oscillatorCount = 0; + bufferSourceCount = 0; + promoteOutgoingConnectSequenceToRingback(); // UK ringback burst: 4 oscillators; sequence no longer active. expect(oscillatorCount).toBe(4); expect(isOutgoingConnectToneSequenceActive()).toBe(false); oscillatorCount = 0; + bufferSourceCount = 0; + vi.advanceTimersByTime(5000); + // Heartbeat / carrier timers cancelled — only ringback interval may fire. + expect(bufferSourceCount).toBe(0); + expect(oscillatorCount).toBe(4); // one more UK ringback cycle at 3s + stopVoiceCallTones(); + }); + + it('stop during modem cancels carrier and later ringback', () => { + vi.useFakeTimers(); + const hash = 'a1b2' + 'c'.repeat(28); startOutgoingConnectToneSequence(hash); + vi.advanceTimersByTime(2000 + DTMF_BURST_MS + DTMF_TO_RING_GAP_MS); expect(isOutgoingConnectToneSequenceActive()).toBe(true); - vi.advanceTimersByTime(500); + oscillatorCount = 0; + bufferSourceCount = 0; stopVoiceCallTones(); expect(isOutgoingConnectToneSequenceActive()).toBe(false); - oscillatorCount = 0; - vi.advanceTimersByTime(5000); + vi.advanceTimersByTime(MODEM_HANDSHAKE_MS + 5000); expect(oscillatorCount).toBe(0); + expect(bufferSourceCount).toBe(0); }); it('suppresses tones when notif muted', () => { @@ -168,9 +238,11 @@ describe('reticulumVoiceCallTones', () => { startVoiceDialTone(); startVoiceRingback(); startOutgoingConnectToneSequence('a'.repeat(32)); + promoteOutgoingConnectSequenceToRingback(); playVoiceReorderTone(); playVoiceBusyTone(); playVoiceFailTone(); expect(oscillatorCount).toBe(0); + expect(bufferSourceCount).toBe(0); }); }); diff --git a/src/renderer/lib/reticulumVoiceCallTones.ts b/src/renderer/lib/reticulumVoiceCallTones.ts index a5e73b304..fa18ee16a 100644 --- a/src/renderer/lib/reticulumVoiceCallTones.ts +++ b/src/renderer/lib/reticulumVoiceCallTones.ts @@ -12,20 +12,36 @@ let busyStopTimer: ReturnType | null = null; let dialOscillators: OscillatorNode[] = []; let dialGain: GainNode | null = null; -/** Outbound connect sequence: 2s dial → DTMF → ringback. */ +/** Outbound connect sequence: 2s dial → DTMF → modem handshake → carrier. */ let connectSequenceActive = false; let connectSequenceHash: string | null = null; let dialPhaseTimer: ReturnType | null = null; -let dtmfToRingTimer: ReturnType | null = null; +let dtmfToModemTimer: ReturnType | null = null; + +/** Modem handshake / carrier soundscape nodes (stopped on promote / hangup). */ +let modemStoppables: { stop: (when?: number) => void; disconnect: () => void }[] = []; +let modemDisconnectables: { disconnect: () => void }[] = []; +let modemCarrierStartTimer: ReturnType | null = null; +let modemHeartbeatTimer: ReturnType | null = null; const OUTGOING_DIAL_MS = 2000; const DTMF_ON_S = 0.12; const DTMF_GAP_S = 0.06; /** 4 × on + 3 × gap (last gap omitted) = 660ms. */ export const DTMF_BURST_MS = Math.round(4 * DTMF_ON_S * 1000 + 3 * DTMF_GAP_S * 1000); -/** Silence after last DTMF digit before UK ringback. */ +/** Silence after last DTMF digit before modem handshake. */ export const DTMF_TO_RING_GAP_MS = 250; +const MODEM_ANSWER_S = 0.45; +const MODEM_CHIRP_S = 0.35; +const MODEM_TRAIN_S = 0.5; +/** Wall-clock duration of one-shot handshake before carrier bed starts. */ +export const MODEM_HANDSHAKE_MS = Math.round( + (MODEM_ANSWER_S + MODEM_CHIRP_S + MODEM_TRAIN_S) * 1000, +); +const MODEM_HEARTBEAT_MS = 2800; +const MODEM_HEARTBEAT_CHIRP_S = 0.18; + /** Standard DTMF keypad: nybble 0–F → 0–9, A–D, *, #. */ const DTMF_KEY_BY_NYBBLE = '0123456789ABCD*#' as const; @@ -66,15 +82,15 @@ function clearOutgoingConnectToneSequenceTimers(): void { clearTimeout(dialPhaseTimer); dialPhaseTimer = null; } - if (dtmfToRingTimer != null) { - clearTimeout(dtmfToRingTimer); - dtmfToRingTimer = null; + if (dtmfToModemTimer != null) { + clearTimeout(dtmfToModemTimer); + dtmfToModemTimer = null; } connectSequenceActive = false; connectSequenceHash = null; } -/** True while dial→DTMF phase owns the timeline (before ringback starts). */ +/** True while dial→DTMF→modem owns the timeline (before ringback starts). */ export function isOutgoingConnectToneSequenceActive(): boolean { return connectSequenceActive; } @@ -160,6 +176,49 @@ function stopRingbackInterval(): void { } } +function stopModemConnectingSoundscape(): void { + if (modemCarrierStartTimer != null) { + clearTimeout(modemCarrierStartTimer); + modemCarrierStartTimer = null; + } + if (modemHeartbeatTimer != null) { + clearInterval(modemHeartbeatTimer); + modemHeartbeatTimer = null; + } + for (const node of modemStoppables) { + try { + node.stop(); + } catch { + // catch-no-log-ok already stopped + } + try { + node.disconnect(); + } catch { + // catch-no-log-ok + } + } + modemStoppables = []; + for (const node of modemDisconnectables) { + try { + node.disconnect(); + } catch { + // catch-no-log-ok + } + } + modemDisconnectables = []; +} + +function trackModemStoppable(node: { + stop: (when?: number) => void; + disconnect: () => void; +}): void { + modemStoppables.push(node); +} + +function trackModemDisconnectable(node: { disconnect: () => void }): void { + modemDisconnectables.push(node); +} + /** Map peer identity/destination hash → 4 DTMF keys (stable per peer). */ export function dtmfKeysFromPeerHash(hash: string): string { // Full 32-hex fold — prefix-only made many peers sound identical. @@ -209,6 +268,146 @@ function scheduleRingbackBurst(ctx: AudioContext): void { } } +function scheduleModemChirp( + ctx: AudioContext, + startTime: number, + durationS: number, + gainLevel: number, +): void { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(1200, startTime); + osc.frequency.linearRampToValueAtTime(2400, startTime + durationS); + gain.gain.setValueAtTime(gainLevel, startTime); + gain.gain.exponentialRampToValueAtTime(0.0001, startTime + durationS); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(startTime); + osc.stop(startTime + durationS); + trackModemStoppable(osc); + trackModemDisconnectable(gain); +} + +function playModemHandshake(ctx: AudioContext): void { + let t = ctx.currentTime; + + // V.25-style 2100 Hz answer tone + const ansOsc = ctx.createOscillator(); + const ansGain = ctx.createGain(); + ansOsc.frequency.setValueAtTime(2100, t); + ansGain.gain.setValueAtTime(0.1, t); + ansGain.gain.exponentialRampToValueAtTime(0.0001, t + MODEM_ANSWER_S); + ansOsc.connect(ansGain); + ansGain.connect(ctx.destination); + ansOsc.start(t); + ansOsc.stop(t + MODEM_ANSWER_S); + trackModemStoppable(ansOsc); + trackModemDisconnectable(ansGain); + t += MODEM_ANSWER_S; + + // Sweeping chirp + scheduleModemChirp(ctx, t, MODEM_CHIRP_S, 0.06); + t += MODEM_CHIRP_S; + + // Brief bandpass training noise + const bufferSize = Math.max(1, Math.floor(ctx.sampleRate * MODEM_TRAIN_S)); + const noiseBuffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = noiseBuffer.getChannelData(0); + for (let i = 0; i < bufferSize; i += 1) { + data[i] = Math.random() * 2 - 1; + } + const noise = ctx.createBufferSource(); + noise.buffer = noiseBuffer; + const bandpass = ctx.createBiquadFilter(); + bandpass.type = 'bandpass'; + bandpass.frequency.setValueAtTime(1800, t); + bandpass.Q.setValueAtTime(2.5, t); + const noiseGain = ctx.createGain(); + noiseGain.gain.setValueAtTime(0.1, t); + noiseGain.gain.setValueAtTime(0.1, t + MODEM_TRAIN_S - 0.05); + noiseGain.gain.linearRampToValueAtTime(0.0001, t + MODEM_TRAIN_S); + noise.connect(bandpass); + bandpass.connect(noiseGain); + noiseGain.connect(ctx.destination); + noise.start(t); + noise.stop(t + MODEM_TRAIN_S); + trackModemStoppable(noise); + trackModemDisconnectable(bandpass); + trackModemDisconnectable(noiseGain); +} + +function startModemCarrierBed(ctx: AudioContext): void { + const now = ctx.currentTime; + + // Quiet continuous 1800 Hz carrier sine + const carrierOsc = ctx.createOscillator(); + const carrierGain = ctx.createGain(); + carrierOsc.frequency.setValueAtTime(1800, now); + carrierGain.gain.setValueAtTime(0.035, now); + carrierOsc.connect(carrierGain); + carrierGain.connect(ctx.destination); + carrierOsc.start(now); + trackModemStoppable(carrierOsc); + trackModemDisconnectable(carrierGain); + + // Soft looping bandpass noise bed + const bufferSize = Math.max(1, Math.floor(ctx.sampleRate * 1.0)); + const noiseBuffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = noiseBuffer.getChannelData(0); + for (let i = 0; i < bufferSize; i += 1) { + data[i] = Math.random() * 2 - 1; + } + const noise = ctx.createBufferSource(); + noise.buffer = noiseBuffer; + noise.loop = true; + const bandpass = ctx.createBiquadFilter(); + bandpass.type = 'bandpass'; + bandpass.frequency.setValueAtTime(1800, now); + bandpass.Q.setValueAtTime(1.8, now); + const noiseGain = ctx.createGain(); + noiseGain.gain.setValueAtTime(0.045, now); + noise.connect(bandpass); + bandpass.connect(noiseGain); + noiseGain.connect(ctx.destination); + noise.start(now); + trackModemStoppable(noise); + trackModemDisconnectable(bandpass); + trackModemDisconnectable(noiseGain); +} + +function playModemHeartbeatChirp(): void { + if (!connectSequenceActive) return; + withRunningContext((ctx) => { + scheduleModemChirp(ctx, ctx.currentTime, MODEM_HEARTBEAT_CHIRP_S, 0.04); + }); +} + +/** + * One-shot modem handshake, then quiet continuous carrier until connect/fail. + * Idempotent while already in the soundscape (caller gates via sequence timers). + */ +function startModemConnectingSoundscape(): void { + if (isNotifMuted()) return; + stopModemConnectingSoundscape(); + withRunningContext((ctx) => { + playModemHandshake(ctx); + }); + modemCarrierStartTimer = setTimeout(() => { + modemCarrierStartTimer = null; + if (!connectSequenceActive) return; + withRunningContext((ctx) => { + startModemCarrierBed(ctx); + }); + if (modemHeartbeatTimer != null) { + clearInterval(modemHeartbeatTimer); + } + modemHeartbeatTimer = setInterval(() => { + playModemHeartbeatChirp(); + }, MODEM_HEARTBEAT_MS); + }, MODEM_HANDSHAKE_MS); +} + /** Continuous US dial tone (350+440 Hz) while connecting. Idempotent. */ export function startVoiceDialTone(): void { if (isNotifMuted()) return; @@ -235,6 +434,7 @@ export function startVoiceDialTone(): void { export function startVoiceRingback(): void { if (isNotifMuted()) return; stopDialToneNodes(); + stopModemConnectingSoundscape(); if (ringbackTimer != null) return; withRunningContext((ctx) => { scheduleRingbackBurst(ctx); @@ -247,14 +447,15 @@ export function startVoiceRingback(): void { } /** - * Outbound connect cadence: dial 2s → rapid 4-digit peer DTMF → UK ringback. - * Idempotent for the same peer hash while the dial/DTMF phase is active. + * Outbound connect cadence: dial 2s → rapid 4-digit peer DTMF → modem handshake → carrier. + * Idempotent for the same peer hash while the dial/DTMF/modem phase is active. */ export function startOutgoingConnectToneSequence(peerHash: string): void { const hash = peerHash.replace(/[^0-9a-f]/gi, '').toLowerCase() || '0000'; if (connectSequenceActive && connectSequenceHash === hash) return; clearOutgoingConnectToneSequenceTimers(); + stopModemConnectingSoundscape(); stopDialToneNodes(); stopRingbackInterval(); @@ -267,19 +468,28 @@ export function startOutgoingConnectToneSequence(peerHash: string): void { if (!connectSequenceActive) return; stopDialToneNodes(); playDtmfBurst(dtmfKeysFromPeerHash(hash)); - dtmfToRingTimer = setTimeout(() => { - dtmfToRingTimer = null; + dtmfToModemTimer = setTimeout(() => { + dtmfToModemTimer = null; if (!connectSequenceActive) return; - connectSequenceActive = false; - connectSequenceHash = null; - startVoiceRingback(); + startModemConnectingSoundscape(); }, DTMF_BURST_MS + DTMF_TO_RING_GAP_MS); }, OUTGOING_DIAL_MS); } +/** + * End dial/DTMF/modem ownership and start UK ringback immediately (on connect). + */ +export function promoteOutgoingConnectSequenceToRingback(): void { + clearOutgoingConnectToneSequenceTimers(); + stopModemConnectingSoundscape(); + stopDialToneNodes(); + startVoiceRingback(); +} + /** Stop dial / ringback / cancel pending connect sequence — leaves one-shot busy/fail alone. */ export function stopVoiceProgressTones(): void { clearOutgoingConnectToneSequenceTimers(); + stopModemConnectingSoundscape(); stopDialToneNodes(); stopRingbackInterval(); } diff --git a/src/renderer/lib/reticulumVoiceSession.test.ts b/src/renderer/lib/reticulumVoiceSession.test.ts index 8d6759efb..c4d529ee5 100644 --- a/src/renderer/lib/reticulumVoiceSession.test.ts +++ b/src/renderer/lib/reticulumVoiceSession.test.ts @@ -11,6 +11,7 @@ import { playVoiceBusyTone, playVoiceFailTone, playVoiceReorderTone, + promoteOutgoingConnectSequenceToRingback, startOutgoingConnectToneSequence, startVoiceRingback, stopVoiceCallTones, @@ -100,6 +101,7 @@ vi.mock('@/renderer/components/Toast', () => ({ vi.mock('./reticulumVoiceCallTones', () => ({ startOutgoingConnectToneSequence: vi.fn(), isOutgoingConnectToneSequenceActive: vi.fn(() => false), + promoteOutgoingConnectSequenceToRingback: vi.fn(), startVoiceRingback: vi.fn(), stopVoiceCallTones: vi.fn(), stopVoiceProgressTones: vi.fn(), @@ -239,6 +241,7 @@ describe('reticulumVoiceSession', () => { vi.mocked(stopVoiceCallTones).mockReset(); vi.mocked(startOutgoingConnectToneSequence).mockReset(); vi.mocked(startVoiceRingback).mockReset(); + vi.mocked(promoteOutgoingConnectSequenceToRingback).mockReset(); vi.mocked(isOutgoingConnectToneSequenceActive).mockReturnValue(false); Object.assign(window, { electronAPI: { @@ -311,6 +314,7 @@ describe('reticulumVoiceSession', () => { vi.mocked(startOutgoingConnectToneSequence).mockClear(); syncReticulumVoiceProgressTones('connecting'); expect(startVoiceRingback).toHaveBeenCalled(); + expect(promoteOutgoingConnectSequenceToRingback).not.toHaveBeenCalled(); syncReticulumVoiceProgressTones('ringing'); expect(startVoiceRingback).toHaveBeenCalled(); vi.mocked(stopVoiceCallTones).mockClear(); @@ -322,10 +326,14 @@ describe('reticulumVoiceSession', () => { expect(stopVoiceCallTones).toHaveBeenCalledTimes(1); // established only }); - it('connecting/ringing skip ringback while connect sequence is still active', () => { + it('connecting/ringing promotes sequence to ringback while connect sequence is active', () => { vi.mocked(isOutgoingConnectToneSequenceActive).mockReturnValue(true); syncReticulumVoiceProgressTones('connecting'); + expect(promoteOutgoingConnectSequenceToRingback).toHaveBeenCalledTimes(1); + expect(startVoiceRingback).not.toHaveBeenCalled(); + vi.mocked(promoteOutgoingConnectSequenceToRingback).mockClear(); syncReticulumVoiceProgressTones('ringing'); + expect(promoteOutgoingConnectSequenceToRingback).toHaveBeenCalledTimes(1); expect(startVoiceRingback).not.toHaveBeenCalled(); }); diff --git a/src/renderer/lib/reticulumVoiceSession.ts b/src/renderer/lib/reticulumVoiceSession.ts index 14ae7ed01..0474d954a 100644 --- a/src/renderer/lib/reticulumVoiceSession.ts +++ b/src/renderer/lib/reticulumVoiceSession.ts @@ -15,6 +15,7 @@ import { } from '@/renderer/lib/reticulumVoiceAudio'; import { isOutgoingConnectToneSequenceActive, + promoteOutgoingConnectSequenceToRingback, startOutgoingConnectToneSequence, startVoiceRingback, stopVoiceCallTones, @@ -783,8 +784,11 @@ export function syncReticulumVoiceProgressTones(status: string | null | undefine return; } if (status === 'connecting' || status === 'ringing') { - // Outbound sequence owns dial→DTMF→ring; do not skip ahead when WS updates early. - if (isOutgoingConnectToneSequenceActive()) return; + // Cut dial/DTMF/modem immediately and start UK ringback on connect. + if (isOutgoingConnectToneSequenceActive()) { + promoteOutgoingConnectSequenceToRingback(); + return; + } startVoiceRingback(); return; } diff --git a/src/renderer/runtime/useReticulumRuntime.voice.test.ts b/src/renderer/runtime/useReticulumRuntime.voice.test.ts index c6ef605f8..a8f2e4817 100644 --- a/src/renderer/runtime/useReticulumRuntime.voice.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.voice.test.ts @@ -34,6 +34,7 @@ vi.mock('@/renderer/lib/reticulumVoiceCallTones', () => ({ startVoiceRingback: vi.fn(), startOutgoingConnectToneSequence: vi.fn(), isOutgoingConnectToneSequenceActive: vi.fn(() => false), + promoteOutgoingConnectSequenceToRingback: vi.fn(), stopVoiceCallTones: vi.fn(), playVoiceBusyTone: vi.fn(), playVoiceReorderTone: vi.fn(), From 82143ae27f14e3eeacba12ba60e0ef55d6b83d9d Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Tue, 4 Aug 2026 19:50:04 -0600 Subject: [PATCH 2/5] fix(reticulum): allowlist reticulumLastSelfLxmfHash for appSettings:set SQLite mirror of the last LXMF self hash was rejected by the IPC allowlist since #785; localStorage still worked. Add the key and lock it in contracts. --- src/main/database.test.ts | 2 ++ src/main/index.contract.test.ts | 1 + src/main/index.ts | 1 + 3 files changed, 4 insertions(+) diff --git a/src/main/database.test.ts b/src/main/database.test.ts index 62d3bc7a5..1b6135283 100644 --- a/src/main/database.test.ts +++ b/src/main/database.test.ts @@ -414,6 +414,7 @@ describe('app_settings table + message retention defaults (schema sync)', () => expect(INDEX_SOURCE).toContain('meshcoreRoomSync:'); expect(INDEX_SOURCE).toContain('meshcoreRoomLastPost:'); expect(INDEX_SOURCE).toContain('meshcoreRoomCredential:'); + expect(INDEX_SOURCE).toContain('reticulumLastSelfLxmfHash'); expect(INDEX_SOURCE).toContain('reticulumRmapAnnounceIntervalMin'); expect(INDEX_SOURCE).toContain('reticulumRmapReachableOn'); expect(INDEX_SOURCE).toContain('reticulumRmapHeightMeters'); @@ -428,6 +429,7 @@ describe('app_settings table + message retention defaults (schema sync)', () => INDEX_SOURCE.indexOf('APP_SETTINGS_ALLOWED_KEYS'), INDEX_SOURCE.indexOf('APP_SETTINGS_MAX_VALUE_LENGTH'), ); + expect(allowListBlock).toContain("'reticulumLastSelfLxmfHash'"); expect(allowListBlock).toContain("'reticulumRmapAnnounceIntervalMin'"); expect(allowListBlock).toContain("'reticulumRmapReachableOn'"); expect(allowListBlock).toContain("'reticulumRmapHeightMeters'"); diff --git a/src/main/index.contract.test.ts b/src/main/index.contract.test.ts index 596543293..a2b50e31f 100644 --- a/src/main/index.contract.test.ts +++ b/src/main/index.contract.test.ts @@ -149,6 +149,7 @@ describe('Persistent app settings IPC (source contract)', () => { expect(INDEX_SOURCE).toMatch(/key not allowed/); expect(INDEX_SOURCE).toContain("'meshtasticLastRfSelfNodeId'"); expect(INDEX_SOURCE).toContain("'meshcoreLastSelfNodeId'"); + expect(INDEX_SOURCE).toContain("'reticulumLastSelfLxmfHash'"); expect(INDEX_SOURCE).toContain("'use24HourTime'"); expect(INDEX_SOURCE).toContain('meshtasticRemoteAdminKey:'); expect(INDEX_SOURCE).toContain('meshcoreRoomSync:'); diff --git a/src/main/index.ts b/src/main/index.ts index edd089ae3..1794385b2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3539,6 +3539,7 @@ const APP_SETTINGS_ALLOWED_KEYS: ReadonlySet = new Set([ 'use24HourTime', 'alwaysShowMessageActions', 'reticulumAutostart', + 'reticulumLastSelfLxmfHash', 'reticulumRmapAnnounceIntervalMin', 'reticulumRmapReachableOn', 'reticulumRmapHeightMeters', From a80b0094b8d017d671c6d32aa9b0f69bfc449b7c Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Tue, 4 Aug 2026 20:09:05 -0600 Subject: [PATCH 3/5] feat(reticulum): add encrypted LXMF paper message create and ingest Wire sidecar paper APIs, deep-link/QR ingest, Chat share/scan UI, and Network Scan/import so encrypted paper messages can be exchanged without RF. --- docs/reticulum-sidecar-ipc.md | 2 + docs/reticulum.md | 3 +- reticulum-sidecar/src/api/lxmf.rs | 66 +++++- reticulum-sidecar/src/api/mod.rs | 2 + reticulum-sidecar/src/stack/live.rs | 223 +++++++++++++++++- reticulum-sidecar/src/stack/lxmf_delivery.rs | 114 +++++++++ reticulum-sidecar/src/stack/lxmf_outbound.rs | 11 +- reticulum-sidecar/src/stack/mod.rs | 37 ++- reticulum-sidecar/src/stack/types.rs | 8 + .../components/ChatDmPaperControls.tsx | 220 +++++++++++++++++ src/renderer/components/ChatPanel.tsx | 25 +- .../ReticulumMessageStatusBadge.test.tsx | 11 +- .../ReticulumMessageStatusBadge.tsx | 15 +- .../components/ReticulumNetworkPanel.test.tsx | 13 + .../components/ReticulumNetworkPanel.tsx | 63 ++--- .../hooks/useMeshClientDeepLink.test.tsx | 39 ++- src/renderer/hooks/useMeshClientDeepLink.tsx | 11 +- .../lib/meshClientDeepLinkApply.test.ts | 34 +++ src/renderer/lib/meshClientDeepLinkApply.ts | 31 +++ .../reticulum/classifyReticulumVia.test.ts | 2 + .../lib/reticulum/classifyReticulumVia.ts | 3 +- .../reticulum/createReticulumPaperMessage.ts | 104 ++++++++ .../reticulum/handleReticulumQrIngest.test.ts | 56 +++++ .../lib/reticulum/handleReticulumQrIngest.ts | 84 +++++++ src/renderer/lib/storeRecordAdapters.ts | 4 +- src/renderer/lib/types.ts | 1 + src/renderer/locales/cs/translation.json | 30 ++- src/renderer/locales/de/translation.json | 30 ++- src/renderer/locales/en/translation.json | 26 +- src/renderer/locales/es/translation.json | 30 ++- src/renderer/locales/fr/translation.json | 30 ++- src/renderer/locales/id/translation.json | 30 ++- src/renderer/locales/it/translation.json | 30 ++- src/renderer/locales/ja/translation.json | 30 ++- src/renderer/locales/ko/translation.json | 30 ++- src/renderer/locales/nl/translation.json | 30 ++- src/renderer/locales/pl/translation.json | 30 ++- src/renderer/locales/pt-BR/translation.json | 30 ++- src/renderer/locales/ru/translation.json | 30 ++- src/renderer/locales/tr/translation.json | 30 ++- src/renderer/locales/uk/translation.json | 30 ++- src/renderer/locales/zh/translation.json | 30 ++- src/renderer/stores/messageStore.ts | 1 + src/shared/meshClientDeepLink.test.ts | 16 +- src/shared/meshClientDeepLink.ts | 32 ++- 45 files changed, 1571 insertions(+), 136 deletions(-) create mode 100644 src/renderer/components/ChatDmPaperControls.tsx create mode 100644 src/renderer/lib/reticulum/createReticulumPaperMessage.ts create mode 100644 src/renderer/lib/reticulum/handleReticulumQrIngest.test.ts create mode 100644 src/renderer/lib/reticulum/handleReticulumQrIngest.ts diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index 3bb0b996f..8166dd046 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -90,6 +90,8 @@ Routing bias between **RF** (LoRa / RNode) and **network** (TCP/UDP/I2P/gateway/ | Method | Path | Body / notes | Response | | ------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | POST | `/api/v1/lxmf/send` | `{ destination_hash, text, reply_to_hash?, reply_to_id?, reply_preview_text? }` | Live: stamps LXMF `FIELD_REPLY_TO` (0x30) / optional `FIELD_REPLY_QUOTE` (0x31) before sign; `{ ok, delivery_method?, delivery_status?, sent_via?, message? }` or `{ ok: false, error: "no_propagation_node" }`. **`delivery_status` on this response is initial enqueue state only** (`queued` or `sending`) — not delivery confirmation. Stub: `{ ok, sent_via?, message? }` | +| POST | `/api/v1/lxmf/paper/create` | `{ destination_hash, text, reply_to_hash?, reply_to_id?, reply_preview_text? }` | Live: signed `DeliveryMethod::Paper` + `to_paper_uri` (encrypt to peer pubkey in `known_identities`); **no** network send. `{ ok, uri, message_hash, delivery_method: "paper", message? }` or `{ ok: false, error: "identity_unknown" \| "paper_too_large" \| "identity_not_configured" }`. Emits WS `lxmf_message` outbound with `delivery_method`/`sent_via`/`received_via`=`paper` and `delivery_status: "delivered"`. Stub: `{ ok: false, error: "identity_not_configured" }` | +| POST | `/api/v1/lxmf/paper/ingest` | `{ uri }` (`lxm://` base64url paper blob) | Live: `from_paper_uri` + local identity decrypt + `ingest_lxm_uri` → delivery callback / WS `lxmf_message` inbound (`delivery_method`/`received_via`=`paper`). `{ ok, message? }` or `{ ok: false, error: "invalid_uri" \| "decrypt_failed" \| "identity_not_configured" }`. Stub: `{ ok: false, error: "identity_not_configured" }` | | POST | `/api/v1/lxmf/reaction` | `{ destination_hash, target_hash, emoji }` | `{ ok, message? }` | | GET | `/api/v1/lxmf/recent` | `?since_ts=` (ms, optional), `?since_seq=` (opaque `ring_seq`, optional), `?limit=` (default 200, max 500) | `{ messages: [], ring_len }` — ring buffer of recent **inbound** LXMF payloads for WS lag/reconnect catch-up (not durable across sidecar restart; capped at 200). Rows are chronological (oldest→newest) and each accepted row is stamped with monotonic `ring_seq`. Cursor: `since_ts` alone keeps `timestamp > since_ts`; with `since_seq`, keep rows after the complete `(since_ts, since_seq)` cursor (`timestamp > since_ts` **or** same-ms with `ring_seq > since_seq`) so same-ms twins remain recoverable without reprocessing the boundary; `ring_len` is current buffer occupancy | | DELETE | `/api/v1/lxmf/messages/{hash}` | | `{ ok }` | diff --git a/docs/reticulum.md b/docs/reticulum.md index 6c180d6d2..9800600c5 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -264,7 +264,7 @@ When multiple enabled local RNode interfaces are connected, the interface list s - **Identity:** generate BIP-39 recovery phrase, import **private key** (paste or file picker via `reticulum:showIdentityImportDialog`), import **backup JSON**, export with passphrase, display name; **replace identity** confirm when keys already exist (`replace: true` on generate/import) - **Identity slots:** Network panel lists local slots (`GET /api/v1/identities`), create / switch / delete (`POST /api/v1/identities`, `/switch`, `/delete`). Create/switch are serialized and commit the active pointer only after the working key is applied; the sidecar restarts after a successful change. Soft cap **16** slots; display names are sanitized (control chars rejected, max 128 chars). -- **Identity / contact QR:** share via `QrCodeImage` — Columba-compatible **`lxma://:`** when the sidecar reports a public key (identity Network QR and peer detail when known); otherwise mesh-client **`lxm://identity/…`** / **`lxm://contact/…`**. Ingest via paste/file/camera (`QrIngestControl`) including `lxma://` (registers pubkey + saved contact). OS deep links use the registered **`lxm://`** scheme (`electron-builder.yml`); `lxma://` / `meshcore://` are handled when opened/pasted (not OS-registered). External contact imports require confirmation (`MeshClientDeepLinkHost`). Encrypted LXMF paper messages are not supported yet. +- **Identity / contact QR:** share via `QrCodeImage` — Columba-compatible **`lxma://:`** when the sidecar reports a public key (identity Network QR and peer detail when known); otherwise mesh-client **`lxm://identity/…`** / **`lxm://contact/…`**. Ingest via paste/file/camera (`QrIngestControl` under Network **Scan / import**) including `lxma://` (registers pubkey + saved contact) and encrypted **LXMF paper** `lxm://` blobs (`POST /api/v1/lxmf/paper/ingest`). OS deep links use the registered **`lxm://`** scheme (`electron-builder.yml`); `lxma://` / `meshcore://` are handled when opened/pasted (not OS-registered). External contact imports require confirmation (`MeshClientDeepLinkHost`). - **Peer fingerprint verification:** Peer detail can mark a contact verified (pins `verified_identity_hash` + `verified_at` in SQLite via `db:setReticulumDestinationVerified`) and warns on mismatch when the live announce hash drifts. - **Header self label:** when configured, the app header shows your Network **display name** (`reticulumSelfNodeLabel.ts`) — not a hash-prefix stub; omit the `Node:` label when no real name is set - **Identity vault:** optional passcode (minimum 8 characters) to encrypt secrets in the main process; unlock is rate-limited @@ -476,6 +476,7 @@ Transfers require a **high-speed** path (TCP/network); LoRa/BLE-only destination - **AGPL sidecar** — separate process and license from the MIT Electron shell - **LXST voice calls** — integrated via rsLXST `TelephonyService` in the sidecar (`/api/v1/voice/*` + WS `voice.*`). Renderer owns mic/speaker (`getUserMedia` / Web Audio); Call controls live on Peers rows and Chat DM (no separate Voice tab). Live interop with Ratspeak / Python LXST should be verified manually on a real mesh. - **LRGP games** — integrated via sibling [lrgp-rs](https://github.com/ratspeak/lrgp-rs) (`LrgpRouter` + `LrgpStore` in the sidecar). Reticulum **Games** tab (`Gamepad2`) for Tic-Tac-Toe and Chess; Challenge from Peers / Chat DM. Dedicated IPC `reticulum:games*` (generic proxy rejects `/api/v1/games/*`). WS `games.update` / `games.action_result`. Wire-compatible with Ratspeak; see [reticulum-games-parity.md](reticulum-games-parity.md). +- **LXMF paper messages** — create via Chat **Share as paper** (`POST /api/v1/lxmf/paper/create` → QR/`lxm://` URI); ingest via Network **Scan / import**, Chat scan/paste, or OS `lxm://` deep link (`POST /api/v1/lxmf/paper/ingest`). Requires peer pubkey for create; local identity must match paper destination for decrypt. - **Hardware identity (YubiKey/PIV)** — not wired - **In-app firmware download** — local `.zip` pick only diff --git a/reticulum-sidecar/src/api/lxmf.rs b/reticulum-sidecar/src/api/lxmf.rs index 308b5f494..290725acb 100644 --- a/reticulum-sidecar/src/api/lxmf.rs +++ b/reticulum-sidecar/src/api/lxmf.rs @@ -4,7 +4,10 @@ use axum::Json; use axum::extract::{Path, Query, State}; use serde::Deserialize; -use crate::stack::{LxmfReactionRequest, LxmfSendRequest, StackHandle}; +use crate::stack::{ + LxmfPaperCreateRequest, LxmfPaperIngestRequest, LxmfReactionRequest, LxmfSendRequest, + StackHandle, +}; pub async fn lxmf_send( State(stack): State>, @@ -16,6 +19,44 @@ pub async fn lxmf_send( } } +pub async fn lxmf_paper_create( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack.lxmf_paper_create(body).await { + Ok(payload) => Json(payload), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +/// Normalize paper ingest transport errors to stable API codes for the renderer. +pub(crate) fn map_paper_ingest_error(e: String) -> String { + if e == "invalid_uri" + || e == "decrypt_failed" + || e == "identity_not_configured" + || e == "paper_too_large" + || e == "identity_unknown" + { + e + } else if e.contains("invalid_uri") { + "invalid_uri".to_string() + } else if e.contains("decrypt") { + "decrypt_failed".to_string() + } else { + e + } +} + +pub async fn lxmf_paper_ingest( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack.lxmf_paper_ingest(body.uri).await { + Ok(payload) => Json(payload), + Err(e) => Json(serde_json::json!({ "ok": false, "error": map_paper_ingest_error(e) })), + } +} + pub async fn lxmf_reaction( State(stack): State>, Json(body): Json, @@ -67,7 +108,7 @@ pub async fn list_peers( #[cfg(test)] mod peers_query_tests { - use super::peers_query_forces_refresh; + use super::{map_paper_ingest_error, peers_query_forces_refresh}; #[test] fn peers_query_forces_refresh_accepts_truthy_variants() { @@ -79,6 +120,27 @@ mod peers_query_tests { assert!(!peers_query_forces_refresh(Some("no"))); assert!(!peers_query_forces_refresh(Some("maybe"))); } + + #[test] + fn map_paper_ingest_error_preserves_and_normalizes_codes() { + assert_eq!(map_paper_ingest_error("invalid_uri".into()), "invalid_uri"); + assert_eq!( + map_paper_ingest_error("decrypt_failed".into()), + "decrypt_failed" + ); + assert_eq!( + map_paper_ingest_error("identity_unknown".into()), + "identity_unknown" + ); + assert_eq!( + map_paper_ingest_error("paper create: invalid_uri detail".into()), + "invalid_uri" + ); + assert_eq!( + map_paper_ingest_error("paper ingest: decrypt boom".into()), + "decrypt_failed" + ); + } } #[derive(Debug, serde::Deserialize)] diff --git a/reticulum-sidecar/src/api/mod.rs b/reticulum-sidecar/src/api/mod.rs index 89a353b66..30f56c02e 100644 --- a/reticulum-sidecar/src/api/mod.rs +++ b/reticulum-sidecar/src/api/mod.rs @@ -94,6 +94,8 @@ pub fn router(stack: Arc) -> Router { ) .route("/api/v1/ble/scan", get(interfaces::ble_scan)) .route("/api/v1/lxmf/send", post(lxmf::lxmf_send)) + .route("/api/v1/lxmf/paper/create", post(lxmf::lxmf_paper_create)) + .route("/api/v1/lxmf/paper/ingest", post(lxmf::lxmf_paper_ingest)) .route("/api/v1/lxmf/reaction", post(lxmf::lxmf_reaction)) .route("/api/v1/lxmf/recent", get(lxmf::list_recent_lxmf)) .route( diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 66f48e27c..9632508a5 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -335,16 +335,20 @@ impl LiveBridge { } // Match path-table iface name to local config (same as outbound) so // TCP hubs named e.g. "RNS Testnet" classify as tcp, not network. - let received_via = cache_for_cb - .lock() - .ok() - .and_then(|cache| cache.get(&sender_hex).cloned()) - .map(|iface_name| { - let config_rows = - config::interfaces_from_config_dir(&config_dir_for_cb).unwrap_or_default(); - classify_path_interface_name(&iface_name, &config_rows).to_string() - }) - .unwrap_or_else(|| "network".into()); + let received_via = if msg.method == DeliveryMethod::Paper { + "paper".to_string() + } else { + cache_for_cb + .lock() + .ok() + .and_then(|cache| cache.get(&sender_hex).cloned()) + .map(|iface_name| { + let config_rows = config::interfaces_from_config_dir(&config_dir_for_cb) + .unwrap_or_default(); + classify_path_interface_name(&iface_name, &config_rows).to_string() + }) + .unwrap_or_else(|| "network".into()) + }; let inbound_sender_name = name_cache_for_cb .lock() .ok() @@ -4063,6 +4067,173 @@ impl LiveBridge { })) } + /// Encode a signed LXMF message as an encrypted `lxm://` paper URI (no network send). + pub async fn create_lxmf_paper( + &self, + req: &LxmfSendRequest, + ) -> Result { + let dest = parse_hash16(&req.destination_hash)?; + let mut identity_known = self + .outbound + .lock() + .map(|d| d.identity_known_for(&req.destination_hash)) + .unwrap_or(false); + if !identity_known { + identity_known = self.ensure_identity_for_direct(&req.destination_hash).await; + } + if !identity_known { + return Ok(serde_json::json!({ + "ok": false, + "error": "identity_unknown", + "destination_hash": req.destination_hash, + })); + } + + let reply_to = parse_optional_reply_to_hash(req.reply_to_hash.as_deref()); + let reply_quote = req + .reply_preview_text + .as_deref() + .map(str::trim) + .filter(|q| !q.is_empty()); + let (msg, message_hash_hex) = self.prepare_signed_outbound_lxmf( + dest, + "", + &req.text, + DeliveryMethod::Paper, + reply_to, + reply_quote, + )?; + + let dest_hex = req.destination_hash.to_lowercase(); + let uri_result = { + let driver = self + .outbound + .lock() + .map_err(|_| "outbound lock poisoned".to_string())?; + msg.to_paper_uri(|plaintext| { + driver + .encrypt_for_destination(&dest_hex, plaintext) + .ok_or_else(|| { + lxmf_core::message::MessageError::PackFailed(format!( + "no identity key for destination {dest_hex}" + )) + }) + }) + }; + let uri = match uri_result { + Ok(uri) => uri, + Err(lxmf_core::message::MessageError::PackFailed(ref s)) + if s.contains("exceeds maximum size") => + { + return Ok(serde_json::json!({ + "ok": false, + "error": "paper_too_large", + })); + } + Err(lxmf_core::message::MessageError::PackFailed(ref s)) + if s.contains("no identity key") => + { + return Ok(serde_json::json!({ + "ok": false, + "error": "identity_unknown", + "destination_hash": req.destination_hash, + })); + } + Err(other) => return Err(format!("paper create: {other:?}")), + }; + + let ts_ms = (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + * 1000) as i64; + let reply_to_hash_echo = reply_to + .map(hex::encode) + .or_else(|| req.reply_to_hash.clone()); + let mut payload = serde_json::json!({ + "sender_hash": self.lxmf_hash_hex, + "sender_name": self.display_name, + "text": req.text, + "timestamp": ts_ms, + "to_hash": req.destination_hash, + "reply_to_hash": reply_to_hash_echo, + "reply_to_id": req.reply_to_id, + "direction": "outbound", + "delivery_method": "paper", + "sent_via": "paper", + "received_via": "paper", + "delivery_status": "delivered", + "message_hash": message_hash_hex.clone(), + }); + if let Some(quote) = reply_quote { + if let Some(obj) = payload.as_object_mut() { + obj.insert( + "reply_preview_text".into(), + serde_json::Value::String(quote.to_string()), + ); + } + } + + Ok(serde_json::json!({ + "ok": true, + "uri": uri, + "message_hash": message_hash_hex, + "delivery_method": "paper", + "message": payload, + })) + } + + /// Decrypt an `lxm://` paper URI with the local identity and deliver as inbound LXMF. + pub async fn ingest_lxmf_paper(&self, uri: &str) -> Result { + let trimmed = uri.trim(); + if trimmed.is_empty() { + return Ok(serde_json::json!({ + "ok": false, + "error": "invalid_uri", + })); + } + + let identity = self.identity.clone(); + let mut router = self.router.lock().await; + let message = match router.ingest_lxm_uri(trimmed, |ciphertext| { + identity + .decrypt(ciphertext, None, false) + .map_err(|_| lxmf_core::message::MessageError::PackFailed("decrypt".into())) + }) { + Ok(message) => message, + Err( + lxmf_core::message::MessageError::InvalidUri(_) + | lxmf_core::message::MessageError::TooShort(_), + ) => { + return Ok(serde_json::json!({ + "ok": false, + "error": "invalid_uri", + })); + } + Err(lxmf_core::message::MessageError::PackFailed(ref s)) if s == "decrypt" => { + return Ok(serde_json::json!({ + "ok": false, + "error": "decrypt_failed", + })); + } + Err(other) => return Err(format!("paper ingest: {other:?}")), + }; + + let payload = lxmf_payload_from_message( + &message, + &self.lxmf_hash_hex, + &self.display_name, + Some("paper"), + None, + "inbound", + None, + ); + Ok(serde_json::json!({ + "ok": true, + "message": payload, + })) + } + pub async fn apply_interfaces(&self, stack: &StackHandle) -> Result<(), String> { let interfaces = stack.list_interfaces().await; tracing::info!( @@ -4232,7 +4403,13 @@ pub(super) fn lxmf_payload_from_message( "timestamp": ts_ms, "to_hash": to_hex, "direction": direction, - "message_hash": message_hash + "message_hash": message_hash, + "delivery_method": match msg.method { + DeliveryMethod::Direct => "direct", + DeliveryMethod::Propagated => "propagated", + DeliveryMethod::Opportunistic => "opportunistic", + DeliveryMethod::Paper => "paper", + }, }); if let Some(via) = received_via { if let Some(obj) = payload.as_object_mut() { @@ -5717,4 +5894,28 @@ mod reply_field_tests { Some(REPLY_QUOTE_MAX_CHARS) ); } + + #[test] + fn lxmf_payload_sets_paper_delivery_method_and_received_via() { + let msg = LxMessage::new( + [0u8; 16], + [1u8; 16], + "", + "paper body", + DeliveryMethod::Paper, + ); + let payload = lxmf_payload_from_message( + &msg, + "aabbccddeeff00112233445566778899", + "Self", + Some("paper"), + None, + "inbound", + Some("Bob"), + ); + assert_eq!(payload["delivery_method"], "paper"); + assert_eq!(payload["received_via"], "paper"); + assert_eq!(payload["text"], "paper body"); + assert_eq!(payload["sender_name"], "Bob"); + } } diff --git a/reticulum-sidecar/src/stack/lxmf_delivery.rs b/reticulum-sidecar/src/stack/lxmf_delivery.rs index 0b832b5c1..bf5e17aa9 100644 --- a/reticulum-sidecar/src/stack/lxmf_delivery.rs +++ b/reticulum-sidecar/src/stack/lxmf_delivery.rs @@ -736,4 +736,118 @@ mod tests { "full inbound_raw queue must log saturation (drop-newest policy)" ); } + + #[test] + fn paper_uri_round_trip_with_identity_crypto() { + use lxmf_core::message::MessageError; + + let recipient = Identity::new(); + let sender = Identity::new(); + let lxmf_hash = Destination::hash_from_name_and_identity(LXMF_APP, Some(&recipient.hash)); + let sender_lxmf = Destination::hash_from_name_and_identity(LXMF_APP, Some(&sender.hash)); + + let mut msg = LxMessage::new( + lxmf_hash, + sender_lxmf, + "", + "paper hello", + DeliveryMethod::Paper, + ); + msg.sign( + sender + .get_signing_key() + .as_ref() + .expect("sender signing key"), + ) + .unwrap(); + + let uri = msg + .to_paper_uri(|plaintext| { + recipient + .encrypt(plaintext, None) + .map_err(|_| MessageError::PackFailed("encrypt".into())) + }) + .expect("to_paper_uri"); + + assert!(uri.starts_with("lxm://")); + + let recovered = LxMessage::from_paper_uri(&uri, |ciphertext| { + recipient + .decrypt(ciphertext, None, false) + .map_err(|_| MessageError::PackFailed("decrypt".into())) + }) + .expect("from_paper_uri"); + assert_eq!(recovered.content, "paper hello"); + assert_eq!(recovered.method, DeliveryMethod::Paper); + assert_eq!(recovered.destination_hash, lxmf_hash); + } + + #[test] + fn paper_uri_wrong_identity_decrypt_fails() { + use lxmf_core::message::MessageError; + + let recipient = Identity::new(); + let wrong = Identity::new(); + let sender = Identity::new(); + let lxmf_hash = Destination::hash_from_name_and_identity(LXMF_APP, Some(&recipient.hash)); + let sender_lxmf = Destination::hash_from_name_and_identity(LXMF_APP, Some(&sender.hash)); + + let mut msg = LxMessage::new(lxmf_hash, sender_lxmf, "", "secret", DeliveryMethod::Paper); + msg.sign( + sender + .get_signing_key() + .as_ref() + .expect("sender signing key"), + ) + .unwrap(); + let uri = msg + .to_paper_uri(|plaintext| { + recipient + .encrypt(plaintext, None) + .map_err(|_| MessageError::PackFailed("encrypt".into())) + }) + .expect("to_paper_uri"); + + let err = LxMessage::from_paper_uri(&uri, |ciphertext| { + wrong + .decrypt(ciphertext, None, false) + .map_err(|_| MessageError::PackFailed("decrypt".into())) + }); + assert!(err.is_err(), "wrong identity must not decrypt paper"); + } + + #[test] + fn paper_uri_oversized_rejected() { + use lxmf_core::constants::PAPER_MDU; + use lxmf_core::message::MessageError; + + let recipient = Identity::new(); + let sender = Identity::new(); + let lxmf_hash = Destination::hash_from_name_and_identity(LXMF_APP, Some(&recipient.hash)); + let sender_lxmf = Destination::hash_from_name_and_identity(LXMF_APP, Some(&sender.hash)); + // Large enough that dest‖ciphertext exceeds PAPER_MDU after identity encryption. + let big = "x".repeat(PAPER_MDU); + let mut msg = LxMessage::new(lxmf_hash, sender_lxmf, "", &big, DeliveryMethod::Paper); + msg.sign( + sender + .get_signing_key() + .as_ref() + .expect("sender signing key"), + ) + .unwrap(); + let err = msg.to_paper_uri(|plaintext| { + recipient + .encrypt(plaintext, None) + .map_err(|_| MessageError::PackFailed("encrypt".into())) + }); + match err { + Err(MessageError::PackFailed(s)) => { + assert!( + s.contains("exceeds maximum size"), + "unexpected pack error: {s}" + ); + } + other => panic!("expected oversized PackFailed, got {other:?}"), + } + } } diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index 5682649c6..2619a9280 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -796,9 +796,14 @@ impl LxmfOutboundDriver { Some(packed) } - fn encrypt_for_destination(&self, dest_hash_hex: &str, plaintext: &[u8]) -> Option> { - let pub_key = self.known_identities.get(&dest_hash_hex.to_lowercase())?; - let remote = Identity::from_public_key(pub_key).ok()?; + /// Encrypt plaintext to a known peer destination identity (Direct/PN/paper). + pub fn encrypt_for_destination( + &self, + dest_hash_hex: &str, + plaintext: &[u8], + ) -> Option> { + let pub_key = self.public_key_for(dest_hash_hex)?; + let remote = Identity::from_public_key(&pub_key).ok()?; remote.encrypt(plaintext, None).ok() } diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index cc4176743..c4ae0283b 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -71,8 +71,9 @@ use persistence::PersistedState; pub use pn_hosting_policy::PnHostingPolicy; use tokio::sync::{Mutex, RwLock, broadcast}; pub use types::{ - AddInterfaceRequest, ContactRow, DiscoveredPropagationRow, InterfaceRow, LxmfReactionRequest, - LxmfSendRequest, NomadNodeRow, NomadServingStatus, PeerRow, RrcHubRow, StackIdentity, + AddInterfaceRequest, ContactRow, DiscoveredPropagationRow, InterfaceRow, + LxmfPaperCreateRequest, LxmfPaperIngestRequest, LxmfReactionRequest, LxmfSendRequest, + NomadNodeRow, NomadServingStatus, PeerRow, RrcHubRow, StackIdentity, }; #[cfg(not(feature = "rns-stack"))] @@ -2393,6 +2394,38 @@ impl StackHandle { Ok(res) } + pub async fn lxmf_paper_create( + &self, + req: LxmfSendRequest, + ) -> Result { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + let res = live.create_lxmf_paper(&req).await?; + if res.get("ok") == Some(&serde_json::Value::Bool(true)) { + if let Some(payload) = res.get("message").cloned() { + self.emit_event("lxmf_message", payload); + } + } + return Ok(res); + } + Ok(serde_json::json!({ + "ok": false, + "error": "identity_not_configured", + })) + } + + pub async fn lxmf_paper_ingest(&self, uri: String) -> Result { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + // ingest_lxm_uri fires the delivery callback (WS lxmf_message); return HTTP body only. + return live.ingest_lxmf_paper(&uri).await; + } + Ok(serde_json::json!({ + "ok": false, + "error": "identity_not_configured", + })) + } + fn maybe_emit_identity_restart(&self) { #[cfg(feature = "rns-stack")] if self.live.is_some() { diff --git a/reticulum-sidecar/src/stack/types.rs b/reticulum-sidecar/src/stack/types.rs index 8fa3fa0b9..4a7618302 100644 --- a/reticulum-sidecar/src/stack/types.rs +++ b/reticulum-sidecar/src/stack/types.rs @@ -271,6 +271,14 @@ pub struct LxmfSendRequest { pub reply_preview_text: Option, } +/// Create an encrypted `lxm://` paper URI (no network send). +pub type LxmfPaperCreateRequest = LxmfSendRequest; + +#[derive(Debug, Clone, Deserialize)] +pub struct LxmfPaperIngestRequest { + pub uri: String, +} + #[derive(Debug, Clone, Deserialize)] pub struct LxmfReactionRequest { pub destination_hash: String, diff --git a/src/renderer/components/ChatDmPaperControls.tsx b/src/renderer/components/ChatDmPaperControls.tsx new file mode 100644 index 000000000..c7dbe5105 --- /dev/null +++ b/src/renderer/components/ChatDmPaperControls.tsx @@ -0,0 +1,220 @@ +import { FileText, QrCode } from 'lucide-react-motion'; +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import QrCodeImage from '@/renderer/components/QrCodeImage'; +import QrIngestControl from '@/renderer/components/QrIngestControl'; +import { useToast } from '@/renderer/components/Toast'; +import { useActiveMeshIdentity } from '@/renderer/hooks/useActiveMeshIdentity'; +import { loadDraftsInitial } from '@/renderer/lib/chatPanelProtocolStorage'; +import { createReticulumPaperMessage } from '@/renderer/lib/reticulum/createReticulumPaperMessage'; +import { handleReticulumQrIngest } from '@/renderer/lib/reticulum/handleReticulumQrIngest'; +import { RETICULUM_DM_HEADER_ACTION_CLASS } from '@/renderer/lib/reticulumDmHeaderActions'; +import { writeClipboardText } from '@/renderer/lib/writeClipboardText'; + +export interface ChatDmPaperShareControlProps { + lxmfPeerHash: string; + viewKey: string; + sidecarRunning: boolean; + className?: string; +} + +/** + * Chat DM header: open a modal to create an encrypted LXMF paper QR from the draft (or typed text). + */ +export function ChatDmPaperShareControl({ + lxmfPeerHash, + viewKey, + sidecarRunning, + className = RETICULUM_DM_HEADER_ACTION_CLASS, +}: Readonly) { + const { t } = useTranslation(); + const { addToast } = useToast(); + const { focusedIdentityId } = useActiveMeshIdentity('reticulum'); + const [open, setOpen] = useState(false); + const [text, setText] = useState(''); + const [uri, setUri] = useState(null); + const [busy, setBusy] = useState(false); + + const openModal = useCallback(() => { + const drafts = loadDraftsInitial('reticulum'); + setText((drafts[viewKey] ?? '').trim()); + setUri(null); + setOpen(true); + }, [viewKey]); + + const createPaper = useCallback(async () => { + if (busy || !focusedIdentityId) return; + setBusy(true); + try { + const result = await createReticulumPaperMessage({ + identityId: focusedIdentityId, + destinationHash: lxmfPeerHash, + text, + }); + if (!result.ok) { + addToast(t(result.errorKey), 'error'); + return; + } + setUri(result.uri); + } finally { + setBusy(false); + } + }, [addToast, busy, focusedIdentityId, lxmfPeerHash, t, text]); + + if (!open) { + return ( + + ); + } + + return ( + <> + +
+
+

+ {t('chatPanel.shareAsPaperTitle')} +

+

{t('chatPanel.shareAsPaperHint')}

+ {uri == null ? ( + <> +