From f006fad14fbb5e82e7f54bcee00c97533e4804d3 Mon Sep 17 00:00:00 2001 From: "Robert M. Talbot" Date: Fri, 16 Oct 2026 11:38:49 -0600 Subject: [PATCH 1/4] feat(tak): direct messages between ATAK and mesh companions Each radio's "Messaging in ATAK" section gains a "Direct messages" switch. With it on: - Companions this radio can message (a MeshCore companion whose key it holds, any Meshtastic node) get a chat endpoint on their marker, so ATAK offers to message them. Repeaters, room servers and sensors do not. - A message to a companion's contact goes out from this radio through the Chat send path as a direct message, prefixed [CALLSIGN], and shows in Chat. - A DM heard from a companion goes to the ATAK device that last wrote to it, or to every connected device when none has; it is held (with channel messages, 50 max, 24 h) while the device it is for is away. Retried copies are relayed once. Command replies from repeaters and room servers are not relayed. - A companion with no marker on the map gets a contact at 0, 0 for a day after the last message, under its marker's uid, so a later marker replaces it. - A message ATAK cannot deliver (DMs off for the radio, or not a node this radio can reach) gets a note in its conversation saying why. - DMs and chat endpoints are never sent to the remote TAK server. New IPC: tak:pushDirectMessage and tak:setDirectMessaging. TakInboundChat now carries a target (channel or direct) in place of channelKey. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EHFPfW7vZXpRdR3tBBTX3r --- README.md | 1 + src/main/index.ipc-security.test.ts | 2 + src/main/ipc/tak-handlers.test.ts | 52 +++ src/main/ipc/tak-handlers.ts | 29 ++ src/main/tak-server-manager.test.ts | 294 +++++++++++++- src/main/tak-server-manager.ts | 373 ++++++++++++++++-- src/main/tak/cot-converter.test.ts | 31 +- src/main/tak/cot-converter.ts | 27 +- src/main/tak/geochat-cot.test.ts | 71 ++++ src/main/tak/geochat-cot.ts | 129 +++++- src/main/tak/node-update.test.ts | 11 + src/main/tak/node-update.ts | 1 + src/preload/index.ts | 6 + .../TakChannelRelaySection.test.tsx | 31 +- .../components/TakChannelRelaySection.tsx | 50 ++- src/renderer/components/TakServerPanel.tsx | 16 +- src/renderer/hooks/useTakInboundChat.test.tsx | 33 +- src/renderer/hooks/useTakInboundChat.ts | 90 +++-- src/renderer/hooks/useTakNodeReplicator.ts | 2 + src/renderer/lib/devElectronApiStub.ts | 2 + .../lib/ingest/meshcoreIngest.test.ts | 51 ++- src/renderer/lib/ingest/meshcoreIngest.ts | 11 + src/renderer/lib/settingsSearchEntries/tak.ts | 14 + src/renderer/lib/tak/takChannelRelay.test.ts | 31 +- src/renderer/lib/tak/takChannelRelay.ts | 19 +- .../lib/tak/takDirectMessages.test.ts | 174 ++++++++ src/renderer/lib/tak/takDirectMessages.ts | 114 ++++++ src/renderer/lib/tak/takInboundChat.test.ts | 59 ++- src/renderer/lib/tak/takInboundChat.ts | 36 +- src/renderer/lib/tak/takMeshSend.test.ts | 22 ++ src/renderer/lib/tak/takMeshSend.ts | 14 +- .../lib/tak/takServedChannels.test.ts | 1 + src/renderer/lib/takNodeFeed.test.ts | 22 ++ src/renderer/lib/takNodeFeed.ts | 7 +- src/renderer/locales/en/translation.json | 4 +- src/renderer/stores/takChannelStore.ts | 19 + .../stores/takRelayPrefsStore.test.ts | 16 + src/renderer/stores/takRelayPrefsStore.ts | 19 +- src/renderer/vitest.electronApiMock.ts | 2 + src/shared/electron-api.types.ts | 9 + src/shared/tak-types.ts | 36 +- vitest.config.mts | 1 + 42 files changed, 1806 insertions(+), 126 deletions(-) create mode 100644 src/renderer/lib/tak/takDirectMessages.test.ts create mode 100644 src/renderer/lib/tak/takDirectMessages.ts diff --git a/README.md b/README.md index 3714a8a45..e347d13a1 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,7 @@ Mesh-Client supports **three mesh stacks** in one desktop app. Use the **protoco - **Certificate management**: self-signed CA + server + client certificates via node-forge (server cert includes DNS + LAN IP in Subject Alternative Name so EUDs can verify TLS); regenerate anytime from the TAK tab - **Data package generator**: export ATAK-compatible (truststore.p12, client.p12, connection.pref) for direct import on TAK devices — connect host is your LAN IP and must match the server cert SAN; re-generate the package after certificate regeneration or a LAN IP change - **Channels as ATAK contacts**: under **MeshCore messaging in ATAK** and **Meshtastic messaging in ATAK**, choose Off, All channels, or Selected channels. Each served channel becomes a contact on every connected ATAK device, named with its protocol (`#MC Public`, `#MC ops` for a hashtag channel, `#MT 🔒 Ops` for a private one) unless you type your own in its **ATAK name** field, and what the channel hears arrives from that contact. Tick **ATAK can send** for a channel and a message to its contact is transmitted on that channel from this radio, prefixed with the sender's callsign (`[VIPER] on scene`), and shows in Chat like any other; left off, the channel is receive only. Tick **Remote server** to also stream the channel, read only, to the remote TAK server. Sending is off by default, cut to fit one mesh message, and limited to 10 messages a minute per device. Up to 50 messages heard while no ATAK device is connected are held for the first one to connect. Settings from earlier versions carry over: a channel that had a GeoChat room is served, keeps its send setting and its room name (as its ATAK name), and streams to the remote server. Channel settings are kept per channel, not per slot, so a channel moving into another's slot never inherits its settings, and ATAK devices are told to drop the old room contacts. +- **Direct messages with companions**: tick **Direct messages** under a radio's messaging section and every companion that radio can message (a MeshCore companion in its contacts, any Meshtastic node) can be messaged from its marker in ATAK. The message goes out from this radio as a direct message, prefixed with the sender's callsign, and shows in Chat like any other. A companion's replies go to the ATAK device that last wrote to it, or to every connected device when none has, and are held for that device while it is away. A companion with no marker on the map appears at 0, 0 for a day after a message. Direct messages are never sent to the remote TAK server, and a message ATAK cannot deliver (direct messages off, or not a companion this radio can reach) gets a note in its conversation saying why. - **Unit filters**: under **Relayed unit styles**, restyle relayed nodes by name, node type (companions and clients, repeaters and routers, room servers, sensors, trackers), protocol, or a combination, choosing the TAK unit type, team color, and team role. A filter with no name pattern applies to every node of its type or protocol; the first enabled filter that matches wins - **Repeater and room server icons**: under **Relayed unit styles**, give repeaters (MeshCore repeaters, Meshtastic routers, Reticulum interfaces) and room servers each an icon from an ATAK icon set, as a path (`//`) or from the GeoOps WildFire presets that MeshCore TAK Bridge offers (Repeater, Repeater Mobile Relay, Ranger Station and others). The icon follows the node type, so a filter can change a room server's team and role and it keeps its icon. Leave a field empty for the standard symbol. Each TAK device needs the same icon set installed; a device without it draws the standard symbol - Auto-start option (off by default); status indicator in header when running diff --git a/src/main/index.ipc-security.test.ts b/src/main/index.ipc-security.test.ts index e788df1a9..a3139b938 100644 --- a/src/main/index.ipc-security.test.ts +++ b/src/main/index.ipc-security.test.ts @@ -454,6 +454,8 @@ describe('GPS/TAK IPC sender validation (source contract)', () => { 'tak:pushChatMessage', 'tak:setChatChannels', 'tak:retireChatRooms', + 'tak:pushDirectMessage', + 'tak:setDirectMessaging', ] as const; it.each(takChannels)('tak handler %s calls assertIpcSender', (channel) => { diff --git a/src/main/ipc/tak-handlers.test.ts b/src/main/ipc/tak-handlers.test.ts index a865ba72a..1b5bb9374 100644 --- a/src/main/ipc/tak-handlers.test.ts +++ b/src/main/ipc/tak-handlers.test.ts @@ -561,6 +561,58 @@ describe('tak:setChatChannels', () => { }); }); +describe('direct message handlers', () => { + type Handler = (event: unknown, arg?: unknown) => unknown; + + async function register(manager: unknown, channel: string): Promise { + const { ipcMain } = await import('electron'); + const handle = vi.mocked(ipcMain.handle); + handle.mockClear(); + registerTakIpcHandlers({ + idleTakStatus: { running: false, port: 8089, clientCount: 0 }, + ensureTakServerManager: vi.fn(), + getTakServerManager: () => manager as never, + validateTakSettings: vi.fn(), + }); + return handle.mock.calls.find((c) => c[0] === channel)?.[1] as Handler; + } + + const DM = { protocol: 'meshcore', nodeId: 123, senderCallsign: 'Alice', text: 'hi', timeMs: 1 }; + + it('validates a heard direct message and forwards it while a sink is active', async () => { + const sendDirectMessage = vi.fn(); + const handler = await register( + { hasActiveSink: () => true, sendDirectMessage }, + 'tak:pushDirectMessage', + ); + handler({}, DM); + expect(sendDirectMessage).toHaveBeenCalledWith(DM); + expect(assertIpcSender).toHaveBeenCalledWith({}, 'tak:pushDirectMessage'); + expect(() => handler({}, { ...DM, nodeId: -1 })).toThrow(/tak:pushDirectMessage/); + expect(sendDirectMessage).toHaveBeenCalledTimes(1); + }); + + it('drops heard direct messages while no sink is active', async () => { + const sendDirectMessage = vi.fn(); + (await register({ hasActiveSink: () => false, sendDirectMessage }, 'tak:pushDirectMessage'))( + {}, + DM, + ); + (await register(null, 'tak:pushDirectMessage'))({}, DM); + expect(sendDirectMessage).not.toHaveBeenCalled(); + }); + + it('hands the radios with direct messages on to the manager, once it exists', async () => { + const setDirectMessaging = vi.fn(); + const handler = await register({ setDirectMessaging }, 'tak:setDirectMessaging'); + handler({}, ['meshtastic']); + expect(setDirectMessaging).toHaveBeenCalledWith(['meshtastic']); + expect(assertIpcSender).toHaveBeenCalledWith({}, 'tak:setDirectMessaging'); + expect(() => handler({}, ['lora'])).toThrow(/tak:setDirectMessaging/); + expect((await register(null, 'tak:setDirectMessaging'))({}, [])).toBeUndefined(); + }); +}); + describe('tak:retireChatRooms', () => { type Handler = (event: unknown, arg?: unknown) => unknown; diff --git a/src/main/ipc/tak-handlers.ts b/src/main/ipc/tak-handlers.ts index eb08705d7..fa09639cd 100644 --- a/src/main/ipc/tak-handlers.ts +++ b/src/main/ipc/tak-handlers.ts @@ -18,6 +18,8 @@ import { enrollTakClientCertificate } from '../tak/enrollment'; import { parseTakChannelChatMessage, parseTakChatChannels, + parseTakDirectMessage, + parseTakDmProtocols, parseTakRetiredRooms, } from '../tak/geochat-cot'; import { parseTakNodeUpdate } from '../tak/node-update'; @@ -279,6 +281,33 @@ export function registerTakIpcHandlers(deps: TakIpcDeps): void { m.retireChatRooms(parseTakRetiredRooms(rooms)); }); + const directPushes = createIpcRateLimiter({ + max: 120, + windowMs: MS_PER_MINUTE, + label: 'tak:pushDirectMessage', + }); + ipcMain.handle('tak:pushDirectMessage', (event, message: unknown) => { + assertIpcSender(event, 'tak:pushDirectMessage'); + const m = getTakServerManager(); + if (!m?.hasActiveSink()) return; + directPushes.checkOrThrow(); + m.sendDirectMessage(parseTakDirectMessage(message)); + }); + + const directMessagingUpdates = createIpcRateLimiter({ + max: 60, + windowMs: MS_PER_MINUTE, + label: 'tak:setDirectMessaging', + }); + ipcMain.handle('tak:setDirectMessaging', (event, protocols: unknown) => { + assertIpcSender(event, 'tak:setDirectMessaging'); + // As for channels: the renderer sends the setting again when a sink starts. + const m = getTakServerManager(); + if (!m) return; + directMessagingUpdates.checkOrThrow(); + m.setDirectMessaging(parseTakDmProtocols(protocols)); + }); + // ─── Relayed node styling ──────────────────────────────────────── ipcMain.handle('tak:getStyleSettings', (event) => { diff --git a/src/main/tak-server-manager.test.ts b/src/main/tak-server-manager.test.ts index 687d41b02..f456f1f4b 100644 --- a/src/main/tak-server-manager.test.ts +++ b/src/main/tak-server-manager.test.ts @@ -810,11 +810,18 @@ const SELF = (uid: string, callsign: string) => */ const CHAT = ( text: string, - opts: { room?: string; channel?: TakChatChannel; sender?: string; callsign?: string } = {}, + opts: { + room?: string; + /** The contact uid the message is addressed to; defaults to the room's or channel's. */ + id?: string; + channel?: TakChatChannel; + sender?: string; + callsign?: string; + } = {}, ) => { const channel = opts.channel ?? OPS; const room = opts.room ?? channel.name; - const id = opts.room ?? channelContactUid(channel.key); + const id = opts.id ?? opts.room ?? channelContactUid(channel.key); const sender = opts.sender ?? 'ANDROID-1'; const callsign = opts.callsign ?? ' senderCallsign="VIPER"'; return ( @@ -1066,7 +1073,7 @@ describe('TakServerManager chat from local clients', () => { expect(inbound).toEqual([ { - channelKey: OPS.key, + target: { kind: 'channel', channelKey: OPS.key }, senderCallsign: 'VIPER', text: 'on scene', clientId: manager.getConnectedClients()[0]?.id, @@ -1205,3 +1212,284 @@ describe('TakServerManager chat from local clients', () => { warn.mockRestore(); }); }); + +describe('TakServerManager direct messages', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + /** A manager with the local server running and direct messages on for both radios. */ + function dmManager(): TakServerManager { + const manager = chatManager([]); + manager.setDirectMessaging(['meshcore', 'meshtastic']); + return manager; + } + + const ALICE = { + node_id: 123, + protocol: 'meshcore' as const, + latitude: 39.7, + longitude: -105, + long_name: 'Alice', + hw_model: 'Chat', + chatReachable: true, + }; + + /** A client message to a mesh node's contact, as ATAK sends a direct message. */ + const DM = (text: string, to: string, opts: { sender?: string; room?: string } = {}) => + CHAT(text, { room: opts.room ?? 'Alice', id: to, sender: opts.sender }); + + const heardDm = (text: string, nodeId = 123) => ({ + protocol: 'meshcore' as const, + nodeId, + senderCallsign: 'Alice heard', + text, + timeMs: Date.now(), + }); + + const chats = (socket: tls.TLSSocket) => + written(socket).filter((l) => l.includes('type="b-t-f"')); + + function listen(manager: TakServerManager): TAKInboundChat[] { + const inbound: TAKInboundChat[] = []; + manager.on('client-chat', (m: TAKInboundChat) => inbound.push(m)); + return inbound; + } + + it('makes the marker of a companion it can message a chat contact, for local clients only', () => { + const manager = dmManager(); + const remote = { isConnected: () => true, write: vi.fn() }; + (manager as unknown as ChatInternals).remote = remote; + const socket = connect(manager); + manager.onNodeUpdate(ALICE); + manager.onNodeUpdate({ ...ALICE, node_id: 7, long_name: 'Hilltop', hw_model: 'Repeater' }); + manager.onNodeUpdate({ ...ALICE, node_id: 8, long_name: 'Bob', chatReachable: false }); + const local = written(socket); + expect(local.find((l) => l.includes('uid="MC-123"'))).toContain( + '', + ); + // A repeater reads chat from its admin as a command, so it never gets an endpoint. + expect(local.find((l) => l.includes('uid="MC-7"'))).toContain(''); + expect(local.find((l) => l.includes('uid="MC-8"'))).toContain(''); + // Direct messages are never relayed to the remote server, so its copy has no endpoint. + const remoteCot = remote.write.mock.calls.map((c) => String(c[0])); + expect(remoteCot.find((l) => l.includes('uid="MC-123"'))).toContain( + '', + ); + }); + + it('adds and removes endpoints when direct messages are turned on or off for a radio', () => { + const manager = chatManager([]); + const socket = connect(manager); + manager.onNodeUpdate(ALICE); + expect(written(socket)[0]).toContain(''); + vi.mocked(socket.write).mockClear(); + + manager.setDirectMessaging(['meshcore']); + expect(written(socket)).toHaveLength(1); + expect(written(socket)[0]).toContain('endpoint="*:-1:stcp"'); + vi.mocked(socket.write).mockClear(); + + // Unchanged setting: nothing is re-sent. + manager.setDirectMessaging(['meshcore']); + expect(written(socket)).toEqual([]); + + manager.setDirectMessaging([]); + expect(written(socket)).toHaveLength(1); + expect(written(socket)[0]).toContain(''); + }); + + it("takes a message to a companion's contact as a direct message, kept from other clients", () => { + const manager = dmManager(); + manager.onNodeUpdate(ALICE); + const inbound = listen(manager); + const sender = connect(manager, ['ANDROID-1', 'VIPER']); + const other = connect(manager, ['ANDROID-2', 'EAGLE']); + + sender.emit('data', Buffer.from(DM('meet at the gate', 'MC-123'))); + + expect(inbound).toEqual([ + { + target: { kind: 'direct', protocol: 'meshcore', nodeId: 123 }, + senderCallsign: 'VIPER', + text: 'meet at the gate', + clientId: manager.getConnectedClients()[0]?.id, + timeMs: expect.any(Number), + }, + ]); + expect(sender.write).not.toHaveBeenCalled(); + expect(other.write).not.toHaveBeenCalled(); + }); + + it('answers a direct message it cannot send with a note saying why', () => { + const manager = chatManager([]); + manager.setDirectMessaging(['meshcore']); + manager.onNodeUpdate(ALICE); + manager.onNodeUpdate({ ...ALICE, protocol: 'meshtastic', node_id: 456, short_name: 'MT1' }); + manager.onNodeUpdate({ ...ALICE, node_id: 7, long_name: 'Hilltop', hw_model: 'Repeater' }); + const inbound = listen(manager); + const sender = connect(manager, ['ANDROID-1', 'VIPER']); + + sender.emit('data', Buffer.from(DM('a', 'MESH-456'))); + sender.emit('data', Buffer.from(DM('bb', 'MC-7'))); + sender.emit('data', Buffer.from(DM('ccc', 'MC-999'))); + sender.emit('data', Buffer.from(DM('dddd', 'RN-42'))); + + expect(inbound).toEqual([]); + const notes = chats(sender); + expect(notes).toHaveLength(4); + expect(notes[0]).toContain('uid0="MESH-456"'); + expect(notes[0]).toContain('mesh-client: Not sent: direct messages are off for this radio.'); + for (const [i, uid] of [ + [1, 'MC-7'], + [2, 'MC-999'], + [3, 'RN-42'], + ] as const) { + expect(notes[i]).toContain(`uid0="${uid}"`); + expect(notes[i]).toContain( + 'mesh-client: Not sent: not a mesh contact this radio can message.', + ); + } + // Each note comes from the contact written to, into the sender's conversation with it. + expect(notes[1]).toContain('chatroom="VIPER" id="ANDROID-1" senderCallsign="Hilltop"'); + }); + + it('still passes a direct message between two TAK devices on to the other clients', () => { + const manager = dmManager(); + const inbound = listen(manager); + const sender = connect(manager, ['ANDROID-1', 'VIPER']); + const other = connect(manager, ['ANDROID-2', 'EAGLE']); + sender.emit('data', Buffer.from(DM('hi eagle', 'ANDROID-2', { room: 'EAGLE' }))); + expect(inbound).toEqual([]); + expect(chats(other)).toHaveLength(1); + expect(chats(other)[0]).toContain('>hi eagle'); + }); + + it('sends a reply only to the device that wrote, holding it while that device is away', () => { + const manager = dmManager(); + manager.onNodeUpdate(ALICE); + const viper = connect(manager, ['ANDROID-1', 'VIPER']); + const eagle = connect(manager, ['ANDROID-2', 'EAGLE']); + viper.emit('data', Buffer.from(DM('there?', 'MC-123'))); + vi.mocked(viper.write).mockClear(); + + manager.sendDirectMessage(heardDm('yes')); + expect(chats(viper)).toHaveLength(1); + expect(chats(viper)[0]).toContain('chatroom="VIPER" id="ANDROID-1" senderCallsign="Alice"'); + expect(chats(viper)[0]).toContain('uid0="MC-123"'); + expect(chats(viper)[0]).toContain('>yes'); + expect(chats(eagle)).toEqual([]); + + // The device reconnects under the same uid and gets what came while it was away. + viper.emit('close'); + manager.sendDirectMessage(heardDm('still there?')); + expect(chats(eagle)).toEqual([]); + const back = connect(manager); + back.emit('data', Buffer.from(SELF('ANDROID-1', 'VIPER'))); + expect(chats(back).map((l) => />([^<]*)<\/remarks>/.exec(l)?.[1])).toEqual(['still there?']); + }); + + it('sends a direct message from a companion nobody has written to to every device', () => { + const manager = dmManager(); + manager.onNodeUpdate(ALICE); + const viper = connect(manager, ['ANDROID-1', 'VIPER']); + const eagle = connect(manager, ['ANDROID-2', 'EAGLE']); + manager.sendDirectMessage(heardDm('anyone?')); + expect(chats(viper)).toHaveLength(1); + expect(chats(eagle)).toHaveLength(1); + expect(chats(eagle)[0]).toContain('chatroom="EAGLE" id="ANDROID-2" senderCallsign="Alice"'); + }); + + it("drops a repeater's or room server's direct message, which is a login or command reply", () => { + const manager = dmManager(); + manager.onNodeUpdate({ ...ALICE, node_id: 77, hw_model: 'Repeater' }); + manager.onNodeUpdate({ ...ALICE, node_id: 78, hw_model: 'Room' }); + const viper = connect(manager, ['ANDROID-1', 'VIPER']); + vi.mocked(viper.write).mockClear(); + manager.sendDirectMessage(heardDm('Logged in', 77)); + manager.sendDirectMessage(heardDm('Bot Stats (24h):', 78)); + expect(written(viper)).toEqual([]); + }); + + it('holds a direct message while no device is connected, for the first to identify', () => { + const manager = dmManager(); + manager.onNodeUpdate(ALICE); + manager.sendDirectMessage(heardDm('later')); + const viper = connect(manager); + expect(chats(viper)).toEqual([]); + viper.emit('data', Buffer.from(SELF('ANDROID-1', 'VIPER'))); + expect(chats(viper)).toHaveLength(1); + expect(chats(viper)[0]).toContain('>later'); + }); + + it('gives a companion with no marker a contact at 0, 0 for a day after the last message', () => { + const manager = dmManager(); + const socket = connect(manager, ['ANDROID-1', 'VIPER']); + manager.sendDirectMessage(heardDm('no fix', 55)); + const lines = written(socket); + const contact = lines.find((l) => l.includes('uid="MC-55"') && l.includes('a-f-G-U-C')); + expect(contact).toContain(''); + const now = Date.now(); + expect(contact).toContain(`stale="${new Date(now + 24 * 60 * 60 * 1000).toISOString()}"`); + // The contact comes before the message, so ATAK has somewhere to file it. + expect(lines.indexOf(contact!)).toBeLessThan(lines.findIndex((l) => l.includes('b-t-f'))); + + // A client connecting later gets the contact too. + const lateSocket = mockTlsSocket(); + (manager as unknown as ChatInternals)._handleClient(lateSocket); + expect(written(lateSocket).some((l) => l.includes('uid="MC-55"'))).toBe(true); + + // A day on, it is no longer announced. + vi.advanceTimersByTime(24 * 60 * 60 * 1000); + const dayLater = mockTlsSocket(); + (manager as unknown as ChatInternals)._handleClient(dayLater); + expect(written(dayLater).some((l) => l.includes('uid="MC-55"'))).toBe(false); + }); + + it('lets a client reply to a companion known only from its 0, 0 contact', () => { + const manager = dmManager(); + const inbound = listen(manager); + const viper = connect(manager, ['ANDROID-1', 'VIPER']); + manager.sendDirectMessage(heardDm('no fix', 55)); + viper.emit('data', Buffer.from(DM('copy', 'MC-55'))); + expect(inbound).toHaveLength(1); + expect(inbound[0]?.target).toEqual({ kind: 'direct', protocol: 'meshcore', nodeId: 55 }); + }); + + it('sends no 0, 0 contact for a companion whose marker is on the map', () => { + const manager = dmManager(); + const socket = connect(manager, ['ANDROID-1', 'VIPER']); + manager.onNodeUpdate(ALICE); + vi.mocked(socket.write).mockClear(); + manager.sendDirectMessage(heardDm('hi')); + expect( + written(socket).filter((l) => l.startsWith(' { + const manager = chatManager([]); + manager.setDirectMessaging(['meshtastic']); + const socket = connect(manager, ['ANDROID-1', 'VIPER']); + manager.sendDirectMessage(heardDm('ignored')); + expect(written(socket)).toEqual([]); + + manager.setDirectMessaging(['meshcore', 'meshtastic']); + manager.sendDirectMessage(heardDm('seen', 55)); + socket.emit('close'); + manager.sendDirectMessage(heardDm('held', 55)); + const watcher = connect(manager); + manager.setDirectMessaging(['meshtastic']); + const lines = written(watcher); + expect(lines.some((l) => l.includes('t-x-d-d') && l.includes('(map: Map, key: string, value: V, max: number): void { + map.delete(key); + map.set(key, value); + for (const old of map.keys()) { + if (map.size <= max) return; + map.delete(old); + } } export class TakServerManager extends EventEmitter { @@ -130,8 +173,14 @@ export class TakServerManager extends EventEmitter { private retiredRoomUids = new Set(); /** Channels served to TAK clients, keyed by the uid of the contact that stands for each. */ private chatChannels = new Map(); - /** Heard channel messages held while no local client could be addressed, oldest first. */ - private heldChat: HeldChannelChat[] = []; + /** Heard messages held while no local client could be addressed, oldest first. */ + private heldChat: HeldChat[] = []; + /** Radios whose companions TAK clients may message. */ + private dmProtocols = new Set(); + /** Companions a direct message passed with in the last day, by uid, oldest first. */ + private directContacts = new Map(); + /** Per companion uid, the uid of the TAK device that last sent it a direct message. */ + private lastWriters = new Map(); private chatRoomAnnounceTimer: ReturnType | null = null; private styleSettings: TakStyleSettings; @@ -195,7 +244,7 @@ export class TakServerManager extends EventEmitter { this.emit('remote-status', { ...status }); }); remote.on('connected', () => { - this.forEachFreshCot((cot) => remote.write(cot)); + this.forEachFreshCot((cot) => remote.write(cot), false); }); remote.on('cot', (contact: TAKContact) => { if (this.remote === remote) this.contacts.upsert(contact); @@ -321,8 +370,7 @@ export class TakServerManager extends EventEmitter { if (merged.latitude == null || merged.longitude == null) return; if (!this.hasConnectedSink()) return; - const cot = this.toCot(merged); - if (cot) this.broadcast(cot); + this.broadcastNode(merged); } /** @@ -347,18 +395,75 @@ export class TakServerManager extends EventEmitter { this.remote.write(buildGeochatCot(room)); } if (!this._status.running) return; - if (!this.writeChannelChat(channel, line)) this.holdChat(msg.channelKey, line); + if (!this.writeChannelChat(channel, line)) { + this.holdChat({ kind: 'channel', channelKey: msg.channelKey, line, heldAtMs: Date.now() }); + } } - /** Keep a message for the next client to identify itself, dropping the oldest past the cap. */ - private holdChat(channelKey: string, line: ChannelChatLine): void { - this.heldChat.push({ channelKey, line, heldAtMs: Date.now() }); + /** + * Deliver one direct message heard from a companion: to the device that last wrote to it, or + * to every identified local client when none has. Held when nobody it is for is connected. + * Never sent to the remote server. + */ + sendDirectMessage(msg: TAKDirectMessage): void { + if (!this._status.running || !this.dmProtocols.has(msg.protocol)) return; + // A repeater's or room server's reply to a login or command is not a conversation. + const node = this.cachedNodeFor(msg.protocol, msg.nodeId); + if (node && advertisedNodeKind(node, msg.protocol) !== 'companion') return; + const now = Date.now(); + const contact = this.touchDirectContact(msg.protocol, msg.nodeId, msg.senderCallsign, now); + const line: DirectChatLine = { text: msg.text, timeMs: msg.timeMs }; + const recipientUid = this.lastWriters.get(contact.uid); + if (!this.writeDirectChat(contact, line, recipientUid)) { + this.holdChat({ kind: 'direct', uid: contact.uid, line, recipientUid, heldAtMs: now }); + } + } + + /** + * Turn direct messages from TAK clients on or off per radio. A radio turned off loses its + * companions' 0, 0 contacts and held direct messages; markers of a radio that changed are + * sent again, so they gain or lose their chat endpoint at once. + */ + setDirectMessaging(protocols: TakDmProtocol[]): void { + const next = new Set(protocols); + const changed: readonly string[] = TAK_DM_PROTOCOLS.filter( + (p) => next.has(p) !== this.dmProtocols.has(p), + ); + if (changed.length === 0) return; + const now = Date.now(); + for (const contact of [...this.directContacts.values()]) { + if (next.has(contact.protocol)) continue; + this.directContacts.delete(contact.uid); + if (!this.hasFreshMarker(contact.protocol, contact.nodeId, now)) { + this.writeToClients(cotDeleteEvent(contact.uid, now)); + } + } + this.heldChat = this.heldChat.filter( + (h) => h.kind === 'channel' || this.directContacts.has(h.uid), + ); + this.dmProtocols = next; + const cutoff = now - COT_STALE_MS; + for (const node of this.nodeCache.values()) { + if (node.cachedAtMs < cutoff || !changed.includes(node.protocol)) continue; + const cot = this.toCot(node, true); + if (cot) this.writeToClients(cot); + } + this.scheduleChatRoomAnnouncements(); + } + + /** Keep a message for a client yet to identify itself, dropping the oldest past the cap. */ + private holdChat(item: HeldChat): void { + this.heldChat.push(item); if (this.heldChat.length > TAK_CHAT_HELD_MAX) { this.heldChat.splice(0, this.heldChat.length - TAK_CHAT_HELD_MAX); } } - /** Hand the held messages, oldest first, to a client that has just identified itself. */ + /** + * Hand a client that has just identified itself the held messages for it, oldest first: + * every channel line, and each direct message for it or for no device in particular. Direct + * messages for another device stay held for that one. + */ private flushHeldChat(id: string, client: ConnectedClient): void { if (this.heldChat.length === 0) return; const recipient = this.recipientFor(client); @@ -368,12 +473,112 @@ export class TakServerManager extends EventEmitter { const now = Date.now(); for (const item of held) { if (now - item.heldAtMs > TAK_CHAT_HELD_MAX_AGE_MS) continue; - const channel = this.chatChannels.get(channelContactUid(item.channelKey)); - if (!channel) continue; - this.writeToClient(id, client, buildChannelChatCot(channel, item.line, recipient, now)); + if (item.kind === 'channel') { + const channel = this.chatChannels.get(channelContactUid(item.channelKey)); + if (channel) { + this.writeToClient(id, client, buildChannelChatCot(channel, item.line, recipient, now)); + } + continue; + } + if (item.recipientUid !== undefined && item.recipientUid !== recipient.uid) { + this.heldChat.push(item); + continue; + } + const contact = this.directContacts.get(item.uid); + if (!contact) continue; + if (!this.hasFreshMarker(contact.protocol, contact.nodeId, now)) { + this.writeToClient(id, client, buildDirectContactCot(contact, contact.lastDmMs, now)); + } + this.writeToClient(id, client, buildDirectChatCot(contact, item.line, recipient, now)); } } + /** The cached entry for a mesh node, if one has been heard. */ + private cachedNodeFor(protocol: MeshProtocol, nodeId: number): CachedTakNode | undefined { + return this.nodeCache.get(`${protocol}:${nodeId}`); + } + + /** + * Whether TAK clients may message this node: a companion, on a radio with direct messages on, + * that the renderer can send a DM to or that a DM has passed with. + */ + private isChatReachable(node: CachedTakNode): boolean { + if (node.uid !== undefined) return false; + if (!isTakDmProtocol(node.protocol) || !this.dmProtocols.has(node.protocol)) return false; + if (advertisedNodeKind(node, node.protocol) !== 'companion') return false; + return node.chatReachable === true || this.directContacts.has(cotUidFor(node, node.protocol)); + } + + /** Whether local clients show the node's own marker now, standing in for its contact. */ + private hasFreshMarker(protocol: MeshProtocol, nodeId: number, nowMs: number): boolean { + const node = this.cachedNodeFor(protocol, nodeId); + return ( + node !== undefined && node.cachedAtMs >= nowMs - COT_STALE_MS && !!this.toCot(node, true) + ); + } + + /** The callsign ATAK shows for a companion: its marker's, or else the name it was heard by. */ + private directCallsign(protocol: MeshProtocol, nodeId: number, fallback: string): string { + const node = this.cachedNodeFor(protocol, nodeId); + if (!node) return fallback; + return ( + resolveTakStyle(this.styleSettings, node, protocol)?.callsign || + cotCallsignFor(node, protocol) + ); + } + + /** + * Record that a direct message passed with a companion now. A companion with no marker shown + * gets (or refreshes) its 0, 0 contact; one whose marker had no endpoint gets it again with + * one. + */ + private touchDirectContact( + protocol: TakDmProtocol, + nodeId: number, + heardCallsign: string, + nowMs: number, + ): DirectContact { + const uid = cotUidFor({ node_id: nodeId }, protocol); + const isNew = !this.directContacts.has(uid); + const contact: DirectContact = { + uid, + protocol, + nodeId, + callsign: this.directCallsign(protocol, nodeId, heardCallsign), + lastDmMs: nowMs, + }; + setNewest(this.directContacts, uid, contact, DIRECT_PEERS_MAX); + const node = this.cachedNodeFor(protocol, nodeId); + if (!this.hasFreshMarker(protocol, nodeId, nowMs)) { + this.writeToClients(buildDirectContactCot(contact, nowMs, nowMs)); + } else if (isNew && node && node.chatReachable !== true) { + const cot = this.toCot(node, true); + if (cot) this.writeToClients(cot); + } + if (isNew) this.scheduleChatRoomAnnouncements(); + return contact; + } + + /** + * Deliver one direct message from `contact` to the identified local client whose uid is + * `recipientUid`, or to every identified client when it is unset. Returns whether any got it. + */ + private writeDirectChat( + contact: DirectContact, + line: DirectChatLine, + recipientUid: string | undefined, + ): boolean { + const now = Date.now(); + let delivered = false; + for (const [id, client] of this.clients) { + const recipient = this.recipientFor(client); + if (!recipient || (recipientUid !== undefined && recipient.uid !== recipientUid)) continue; + this.writeToClient(id, client, buildDirectChatCot(contact, line, recipient, now)); + delivered = true; + } + return delivered; + } + /** * Replace the set of channels served to TAK clients. A new channel's contact is announced to * every local client, and announced again when its name or send setting changes; a channel @@ -391,7 +596,9 @@ export class TakServerManager extends EventEmitter { this.writeToClients(buildChannelContactCot(channel, now)); } this.chatChannels = next; - this.heldChat = this.heldChat.filter((h) => next.has(channelContactUid(h.channelKey))); + this.heldChat = this.heldChat.filter( + (h) => h.kind === 'direct' || next.has(channelContactUid(h.channelKey)), + ); this.scheduleChatRoomAnnouncements(); } @@ -411,23 +618,44 @@ export class TakServerManager extends EventEmitter { this.retiredRoomUids = next; } - /** Re-announce channel contacts well inside their stale time so they do not expire in ATAK. */ + /** + * Re-announce channel and companion contacts well inside their stale time so they do not + * expire in ATAK. Runs while there is any contact to announce. + */ private scheduleChatRoomAnnouncements(): void { - if (this.chatRoomAnnounceTimer) clearInterval(this.chatRoomAnnounceTimer); - this.chatRoomAnnounceTimer = null; - if (this.chatChannels.size === 0) return; + const needed = this.chatChannels.size > 0 || this.directContacts.size > 0; + if (needed === (this.chatRoomAnnounceTimer !== null)) return; + if (this.chatRoomAnnounceTimer) { + clearInterval(this.chatRoomAnnounceTimer); + this.chatRoomAnnounceTimer = null; + return; + } this.chatRoomAnnounceTimer = setInterval(() => { this.forEachChatRoomContact((cot) => { this.writeToClients(cot); }); + this.scheduleChatRoomAnnouncements(); }, CHAT_ROOM_CONTACT_STALE_MS / 4); this.chatRoomAnnounceTimer.unref(); } - /** Build the contact event for every served channel and pass each to `send`. */ + /** + * Build the contact event for every served channel, and for every companion a direct message + * passed with in the last day that has no marker shown, and pass each to `send`. Companions + * past the day are forgotten; their contact has gone stale in ATAK by then. + */ private forEachChatRoomContact(send: (cot: string) => void): void { const now = Date.now(); for (const channel of this.chatChannels.values()) send(buildChannelContactCot(channel, now)); + for (const contact of [...this.directContacts.values()]) { + if (now - contact.lastDmMs >= DIRECT_CONTACT_TTL_MS) { + this.directContacts.delete(contact.uid); + continue; + } + if (!this.hasFreshMarker(contact.protocol, contact.nodeId, now)) { + send(buildDirectContactCot(contact, contact.lastDmMs, now)); + } + } } /** The uid and callsign a client reported for itself, once it has reported both. */ @@ -474,10 +702,8 @@ export class TakServerManager extends EventEmitter { const cutoff = Date.now() - COT_STALE_MS; for (const node of this.nodeCache.values()) { if (node.cachedAtMs < cutoff) continue; - const cot = this.toCot(node); - if (cot) { - this.broadcast(cot); - } else if ( + if (this.broadcastNode(node)) continue; + if ( node.latitude != null && node.longitude != null && resolveTakStyle(previous, node, node.protocol) @@ -497,6 +723,19 @@ export class TakServerManager extends EventEmitter { this.writeToClients(cot); } + /** + * Send a node's marker to every sink: local clients get its chat endpoint when it has one, + * the remote server never does, since direct messages are not relayed there. Returns false + * when the node has no marker to send. + */ + private broadcastNode(node: CachedTakNode): boolean { + const local = this.toCot(node, true); + if (!local) return false; + if (this.remote?.isConnected()) this.remote.write(this.toCot(node, false) ?? local); + this.writeToClients(local); + return true; + } + /** Write one CoT event to every local client. */ private writeToClients(cot: string): void { for (const [id, client] of this.clients) this.writeToClient(id, client, cot); @@ -511,13 +750,17 @@ export class TakServerManager extends EventEmitter { } } - /** Styled CoT for a cached node, or null when it has no position or filters exclude it. */ - private toCot(node: CachedTakNode): string | null { + /** + * Styled CoT for a cached node, or null when it has no position or filters exclude it. For + * `local` clients a companion they may message carries a chat endpoint. + */ + private toCot(node: CachedTakNode, local: boolean): string | null { const resolved = resolveTakStyle(this.styleSettings, node, node.protocol); if (!resolved) return null; return meshNodeToCot(node, node.protocol, { style: resolved.style, callsign: resolved.callsign, + chatEndpoint: local && this.isChatReachable(node), }); } @@ -526,11 +769,11 @@ export class TakServerManager extends EventEmitter { * connected. Older entries would already have gone stale on a client that was connected all * along, so sending them now would present old positions as current. */ - private forEachFreshCot(send: (cot: string) => void): void { + private forEachFreshCot(send: (cot: string) => void, local: boolean): void { const cutoff = Date.now() - COT_STALE_MS; for (const node of this.nodeCache.values()) { if (node.cachedAtMs < cutoff) continue; - const cot = this.toCot(node); + const cot = this.toCot(node, local); if (cot) send(cot); } } @@ -694,9 +937,10 @@ export class TakServerManager extends EventEmitter { /** * A GeoChat message from a local client. One to a served channel's contact goes to the other * local clients from that contact and to the renderer, which sends it on the channel; a - * receive-only channel gets a note back instead. Any other message is passed on to the other - * local clients, as a TAK server does. Rebuilt from the parsed fields, never forwarded raw, - * and only under the connection's own uid and callsign. + * receive-only channel gets a note back instead. One to a mesh node's contact is a direct + * message (see handleClientDirectChat). Any other message is passed on to the other local + * clients, as a TAK server does. Rebuilt from the parsed fields, never forwarded raw, and + * only under the connection's own uid and callsign. */ private handleClientChat( id: string, @@ -716,6 +960,11 @@ export class TakServerManager extends EventEmitter { } // The callsign from the connection's own position report wins over the one in the message. const senderCallsign = client.info.callsign || chat.senderCallsign || chat.senderUid; + const node = chat.roomId ? parseMeshNodeUid(chat.roomId) : null; + if (node) { + this.handleClientDirectChat(id, client, chat, node, senderCallsign, nowMs); + return; + } // A message to a channel's contact is for that channel, whatever name the client filed it // under. Anything else is GeoChat between TAK clients, passed on as a TAK server does. const channel = chat.roomId ? this.chatChannels.get(chat.roomId) : undefined; @@ -747,7 +996,55 @@ export class TakServerManager extends EventEmitter { const line: ChannelChatLine = { senderCallsign, text: chat.text, timeMs: nowMs }; this.writeChannelChat(channel, line, id, chat.messageId); const inbound: TAKInboundChat = { - channelKey: channel.key, + target: { kind: 'channel', channelKey: channel.key }, + senderCallsign, + text: chat.text, + clientId: id, + timeMs: nowMs, + }; + this.emit('client-chat', inbound); + } + + /** + * A local client's message to a mesh node's contact. It goes to the renderer, which sends it + * as a direct message, when the node is a companion TAK clients may message; the device is + * then the one the companion's replies go to. Otherwise a note in the conversation says why + * it was not sent. It is never passed on to other clients. + */ + private handleClientDirectChat( + id: string, + client: ConnectedClient, + chat: CotGeochat, + target: { protocol: MeshProtocol; nodeId: number }, + senderCallsign: string, + nowMs: number, + ): void { + const { protocol, nodeId } = target; + const uid = cotUidFor({ node_id: nodeId }, protocol); + const note = (text: string): void => { + const recipient = this.recipientFor(client); + if (!recipient) return; + const contact = { uid, callsign: this.directCallsign(protocol, nodeId, chat.room) }; + const line = { text: `${CHANNEL_NOTE_SENDER}: ${text}`, timeMs: nowMs }; + this.writeToClient(id, client, buildDirectChatCot(contact, line, recipient, nowMs)); + }; + if (!isTakDmProtocol(protocol)) { + note(NOT_REACHABLE_NOTE); + return; + } + if (!this.dmProtocols.has(protocol)) { + note(DM_OFF_NOTE); + return; + } + const node = this.cachedNodeFor(protocol, nodeId); + if (!this.directContacts.has(uid) && !(node && this.isChatReachable(node))) { + note(NOT_REACHABLE_NOTE); + return; + } + if (client.selfUid) setNewest(this.lastWriters, uid, client.selfUid, DIRECT_PEERS_MAX); + this.touchDirectContact(protocol, nodeId, chat.room, nowMs); + const inbound: TAKInboundChat = { + target: { kind: 'direct', protocol, nodeId }, senderCallsign, text: chat.text, clientId: id, @@ -788,7 +1085,7 @@ export class TakServerManager extends EventEmitter { } catch { // catch-no-log-ok: socket may close between connection and flush; not actionable } - }); + }, true); this.forEachChatRoomContact((cot) => { this.writeToClient(id, client, cot); }); diff --git a/src/main/tak/cot-converter.test.ts b/src/main/tak/cot-converter.test.ts index 8fcb7d702..f7633a35c 100644 --- a/src/main/tak/cot-converter.test.ts +++ b/src/main/tak/cot-converter.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { MeshNode } from '../../renderer/lib/types'; -import { cotDeleteEvent, cotPongEvent, meshNodeToCot } from './cot-converter'; +import { cotDeleteEvent, cotPongEvent, meshNodeToCot, parseMeshNodeUid } from './cot-converter'; function makeNode(overrides: Partial = {}): MeshNode { return { @@ -296,3 +296,32 @@ describe('meshNodeToCot user icon', () => { expect(meshNodeToCot(node, 'meshcore')).not.toContain(' { + it('makes the marker a chat contact only when asked', () => { + expect(meshNodeToCot(makeNode(), 'meshcore', { chatEndpoint: true })).toContain( + '', + ); + expect(meshNodeToCot(makeNode(), 'meshcore')).toContain(''); + }); + + it('reads the mesh node a marker uid names', () => { + expect(parseMeshNodeUid('MC-123')).toEqual({ protocol: 'meshcore', nodeId: 123 }); + expect(parseMeshNodeUid('MESH-4294967295')).toEqual({ + protocol: 'meshtastic', + nodeId: 4294967295, + }); + expect(parseMeshNodeUid('RN-9')).toEqual({ protocol: 'reticulum', nodeId: 9 }); + for (const uid of [ + 'MC-0', + 'MC-012', + 'MC-4294967296', + 'MC-', + 'ANDROID-1', + 'MC-1 ', + 'meshtracker-0a1b2c3d', + ]) { + expect(parseMeshNodeUid(uid)).toBeNull(); + } + }); +}); diff --git a/src/main/tak/cot-converter.ts b/src/main/tak/cot-converter.ts index 6bc5b970c..b04378964 100644 --- a/src/main/tak/cot-converter.ts +++ b/src/main/tak/cot-converter.ts @@ -31,6 +31,28 @@ const COT_UID_PREFIX: Record = { /** Every uid prefix this app writes; inbound CoT with one of these is our own node echoed back. */ export const COT_UID_PREFIXES: readonly string[] = Object.values(COT_UID_PREFIX); +/** + * `` saying a contact is reached through the server connection. ATAK only + * offers chat with a contact that has one. + */ +export const COT_CHAT_ENDPOINT = '*:-1:stcp'; + +const MESH_NODE_UID_RE = /^(MESH|MC|RN)-([1-9]\d{0,9})$/; +const PROTOCOL_BY_PREFIX: Record = { + MESH: 'meshtastic', + MC: 'meshcore', + RN: 'reticulum', +}; + +/** The mesh node a marker uid (`MC-123`) names, or null for any other uid. */ +export function parseMeshNodeUid(uid: string): { protocol: MeshProtocol; nodeId: number } | null { + const m = MESH_NODE_UID_RE.exec(uid); + if (!m) return null; + const nodeId = Number(m[2]); + if (nodeId > 0xffffffff) return null; + return { protocol: PROTOCOL_BY_PREFIX[m[1]], nodeId }; +} + const PROTOCOL_LABEL: Record = { meshtastic: 'Meshtastic', meshcore: 'MeshCore', @@ -54,6 +76,8 @@ export interface CotRenderOptions { style?: TakUnitStyle; /** Overrides the callsign derived from the node's names. */ callsign?: string; + /** Make the marker a contact ATAK can message ({@link COT_CHAT_ENDPOINT}). */ + chatEndpoint?: boolean; nowMs?: number; } @@ -167,7 +191,8 @@ export function meshNodeToCot( const remarks = escapeXml(heardRemarks(node, protocol, heardMs)); const battery = node.battery ?? 0; - let detail = ``; + const endpoint = opts.chatEndpoint ? ` endpoint="${COT_CHAT_ENDPOINT}"` : ''; + let detail = ``; if (style.group || style.role) { const name = style.group ? ` name="${escapeXml(style.group)}"` : ''; const role = style.role ? ` role="${escapeXml(style.role)}"` : ''; diff --git a/src/main/tak/geochat-cot.test.ts b/src/main/tak/geochat-cot.test.ts index b5c85b868..f05518623 100644 --- a/src/main/tak/geochat-cot.test.ts +++ b/src/main/tak/geochat-cot.test.ts @@ -3,11 +3,15 @@ import { describe, expect, it } from 'vitest'; import { buildChannelChatCot, buildChannelContactCot, + buildDirectChatCot, + buildDirectContactCot, buildGeochatCot, channelContactUid, geochatSenderUid, parseTakChannelChatMessage, parseTakChatChannels, + parseTakDirectMessage, + parseTakDmProtocols, parseTakRetiredRooms, retiredRoomContactUid, } from './geochat-cot'; @@ -171,6 +175,73 @@ describe('parseTakChatChannels', () => { }); }); +describe('direct messages', () => { + const ALICE = { uid: 'MC-123', callsign: 'Alice' }; + const VIPER = { uid: 'ANDROID-1', callsign: 'VIPER' }; + + it('builds a message from the companion to one device, filed under the companion', () => { + const cot = buildDirectChatCot( + ALICE, + { text: 'meet ', timeMs: NOW - 5000 }, + VIPER, + NOW, + 'm1', + ); + expect(cot).toContain('uid="GeoChat.MC-123.ANDROID-1.m1"'); + expect(cot).toContain('chatroom="VIPER" id="ANDROID-1" senderCallsign="Alice"'); + expect(cot).toContain(''); + expect(cot).toContain('time="2026-10-09T11:59:55.000Z">meet <here>'); + }); + + it('builds a reachable contact at 0, 0 that goes stale a day after the last message', () => { + const cot = buildDirectContactCot({ uid: 'MC-123', callsign: 'A&B' }, NOW - 60_000, NOW); + expect(cot).toContain('uid="MC-123" type="a-f-G-U-C"'); + expect(cot).toContain(''); + expect(cot).toContain('stale="2026-10-10T11:59:00.000Z"'); + }); + + it('accepts a heard direct message and rejects malformed ones', () => { + const msg = { + protocol: 'meshcore', + nodeId: 123, + senderCallsign: ' Alice ', + text: ' hi ', + timeMs: NOW, + }; + expect(parseTakDirectMessage(msg)).toEqual({ + protocol: 'meshcore', + nodeId: 123, + senderCallsign: 'Alice', + text: 'hi', + timeMs: NOW, + }); + for (const bad of [ + null, + { ...msg, protocol: 'reticulum' }, + { ...msg, nodeId: 0 }, + { ...msg, nodeId: 2 ** 32 }, + { ...msg, nodeId: 1.5 }, + { ...msg, senderCallsign: 'a\nb' }, + { ...msg, text: ' ' }, + { ...msg, text: 'x'.repeat(1025) }, + { ...msg, timeMs: -1 }, + ]) { + expect(() => parseTakDirectMessage(bad)).toThrow(/^tak:pushDirectMessage: /); + } + }); + + it('accepts the radios with direct messages on, without repeats', () => { + expect(parseTakDmProtocols(['meshcore', 'meshtastic', 'meshcore'])).toEqual([ + 'meshcore', + 'meshtastic', + ]); + expect(parseTakDmProtocols([])).toEqual([]); + expect(() => parseTakDmProtocols(['reticulum'])).toThrow(/tak:setDirectMessaging/); + expect(() => parseTakDmProtocols('meshcore')).toThrow(/tak:setDirectMessaging/); + }); +}); + describe('retired GeoChat rooms', () => { it('names the contact the previous version announced for a room', () => { expect(retiredRoomContactUid('Ops')).toMatch(/^MESHROOM-[0-9a-f]{8}$/); diff --git a/src/main/tak/geochat-cot.ts b/src/main/tak/geochat-cot.ts index 0b63c4249..352d3190e 100644 --- a/src/main/tak/geochat-cot.ts +++ b/src/main/tak/geochat-cot.ts @@ -3,6 +3,7 @@ import { randomUUID } from 'crypto'; import { isChannelIdentityKey } from '../../shared/channelIdentityKey'; import { isValidLatLon } from '../../shared/geoCoords'; import { + isTakDmProtocol, TAK_CHAT_CHANNELS_MAX, TAK_CHAT_ROOM_UID_PREFIX, TAK_GEOCHAT_CALLSIGN_MAX_LEN, @@ -10,9 +11,12 @@ import { TAK_GEOCHAT_TEXT_MAX_LEN, type TAKChannelChatMessage, type TakChatChannel, + type TAKDirectMessage, + type TakDmProtocol, type TAKGeochatMessage, } from '../../shared/tak-types'; import { escapeXml } from '../../shared/xmlEscape'; +import { COT_CHAT_ENDPOINT } from './cot-converter'; /** GeoChat messages stay in ATAK's history; stale only bounds how long servers relay them. */ const GEOCHAT_STALE_MS = 24 * 60 * 60 * 1000; @@ -112,7 +116,7 @@ export function buildChannelContactCot( const uid = escapeXml(channelContactUid(channel.key)); const time = new Date(nowMs).toISOString(); const stale = new Date(nowMs + CHAT_ROOM_CONTACT_STALE_MS).toISOString(); - const endpoint = channel.send ? ' endpoint="*:-1:stcp"' : ''; + const endpoint = channel.send ? ` endpoint="${COT_CHAT_ENDPOINT}"` : ''; return ( `` + @@ -174,6 +178,20 @@ function label(value: unknown, max: number, field: string, channel: string): str return trimmed; } +/** Message text of 1 to the GeoChat maximum, and a positive send time; throws otherwise. */ +function textAndTime( + m: Record, + channel: string, +): { text: string; timeMs: number } { + if (typeof m.text !== 'string' || !m.text.trim() || m.text.length > TAK_GEOCHAT_TEXT_MAX_LEN) { + throw new Error(`${channel}: text must be 1-${TAK_GEOCHAT_TEXT_MAX_LEN} characters`); + } + if (typeof m.timeMs !== 'number' || !Number.isFinite(m.timeMs) || m.timeMs <= 0) { + throw new Error(`${channel}: timeMs must be a positive number`); + } + return { text: m.text.trim(), timeMs: m.timeMs }; +} + /** Validate an untrusted heard channel message from the renderer. Throws when malformed. */ export function parseTakChannelChatMessage(raw: unknown): TAKChannelChatMessage { if (!raw || typeof raw !== 'object') { @@ -190,17 +208,10 @@ export function parseTakChannelChatMessage(raw: unknown): TAKChannelChatMessage 'senderCallsign', 'tak:pushChatMessage', ); - if (typeof m.text !== 'string' || !m.text.trim() || m.text.length > TAK_GEOCHAT_TEXT_MAX_LEN) { - throw new Error(`tak:pushChatMessage: text must be 1-${TAK_GEOCHAT_TEXT_MAX_LEN} characters`); - } - if (typeof m.timeMs !== 'number' || !Number.isFinite(m.timeMs) || m.timeMs <= 0) { - throw new Error('tak:pushChatMessage: timeMs must be a positive number'); - } const msg: TAKChannelChatMessage = { channelKey, senderCallsign, - text: m.text.trim(), - timeMs: m.timeMs, + ...textAndTime(m, 'tak:pushChatMessage'), }; if (m.latitude != null || m.longitude != null) { if ( @@ -216,6 +227,106 @@ export function parseTakChannelChatMessage(raw: unknown): TAKChannelChatMessage return msg; } +const MAX_NODE_ID = 0xffffffff; + +/** Validate an untrusted heard direct message from the renderer. Throws when malformed. */ +export function parseTakDirectMessage(raw: unknown): TAKDirectMessage { + const channel = 'tak:pushDirectMessage'; + if (!raw || typeof raw !== 'object') throw new Error(`${channel}: message must be an object`); + const m = raw as Record; + if (!isTakDmProtocol(m.protocol)) { + throw new Error(`${channel}: protocol must be meshcore or meshtastic`); + } + const nodeId = m.nodeId; + if ( + typeof nodeId !== 'number' || + !Number.isInteger(nodeId) || + nodeId <= 0 || + nodeId > MAX_NODE_ID + ) { + throw new Error(`${channel}: nodeId must be a node number`); + } + return { + protocol: m.protocol, + nodeId, + senderCallsign: label( + m.senderCallsign, + TAK_GEOCHAT_CALLSIGN_MAX_LEN, + 'senderCallsign', + channel, + ), + ...textAndTime(m, channel), + }; +} + +/** Validate the renderer's list of radios with direct messages on. Throws when malformed. */ +export function parseTakDmProtocols(raw: unknown): TakDmProtocol[] { + if (!Array.isArray(raw) || !raw.every(isTakDmProtocol)) { + throw new Error('tak:setDirectMessaging: expected a list of meshcore and meshtastic'); + } + return [...new Set(raw)]; +} + +/** How long a companion keeps its contact without a marker after the last direct message. */ +export const DIRECT_CONTACT_TTL_MS = 24 * 60 * 60 * 1000; + +/** A companion as a chat contact: its marker's uid and the callsign ATAK shows for it. */ +export interface DirectChatContact { + uid: string; + callsign: string; +} + +/** One line of a direct conversation. */ +export interface DirectChatLine { + text: string; + timeMs: number; +} + +/** + * Contact for a companion a direct message has passed with but that has no marker on the map + * (no position, or hidden by the filters or the age limit). It sits at 0, 0, under the marker's + * uid, so a marker arriving later takes its place, and goes stale a day after `lastDmMs`. + */ +export function buildDirectContactCot( + contact: DirectChatContact, + lastDmMs: number, + nowMs: number = Date.now(), +): string { + const time = new Date(nowMs).toISOString(); + const stale = new Date(Math.max(lastDmMs + DIRECT_CONTACT_TTL_MS, nowMs)).toISOString(); + return ( + `` + + `` + + `` + + `` + + `` + + `` + ); +} + +/** + * A direct message to `recipient` from a companion's contact. ATAK files it under that contact, + * and a reply there comes back addressed to the companion's uid. + */ +export function buildDirectChatCot( + contact: DirectChatContact, + line: DirectChatLine, + recipient: GeochatRecipient, + nowMs: number = Date.now(), + messageId: string = randomUUID(), +): string { + return geochatEvent( + { room: contact.callsign, senderCallsign: contact.callsign, ...line }, + contact.uid, + contact.callsign, + recipient.callsign, + recipient.uid, + nowMs, + messageId, + ); +} + /** * Standard CoT v2.0 GeoChat (`b-t-f`) event for one message. `senderUid` is the sending device's * own uid when a TAK client's message is passed on to the other clients; a heard mesh message diff --git a/src/main/tak/node-update.test.ts b/src/main/tak/node-update.test.ts index 0f829fb71..894a4113c 100644 --- a/src/main/tak/node-update.test.ts +++ b/src/main/tak/node-update.test.ts @@ -126,3 +126,14 @@ describe('parseTakNodeUpdate', () => { expect(update?.long_name).toHaveLength(256); }); }); + +describe('parseTakNodeUpdate chatReachable', () => { + it('keeps a boolean and ignores anything else', () => { + const base = { node_id: 1, protocol: 'meshcore' }; + expect(parseTakNodeUpdate({ ...base, chatReachable: true })?.chatReachable).toBe(true); + expect(parseTakNodeUpdate({ ...base, chatReachable: false })?.chatReachable).toBe(false); + expect(parseTakNodeUpdate({ ...base, chatReachable: 'yes' })).not.toHaveProperty( + 'chatReachable', + ); + }); +}); diff --git a/src/main/tak/node-update.ts b/src/main/tak/node-update.ts index fe00d4ec3..e23e56299 100644 --- a/src/main/tak/node-update.ts +++ b/src/main/tak/node-update.ts @@ -83,6 +83,7 @@ export function parseTakNodeUpdate(raw: unknown): TakNodeUpdate | null { if (hops !== undefined) update.hops_away = hops; if (n.source === 'rf' || n.source === 'mqtt') update.source = n.source; if (n.infrastructure === true) update.infrastructure = true; + if (typeof n.chatReachable === 'boolean') update.chatReachable = n.chatReachable; if (n.uid !== undefined) { if (typeof n.uid !== 'string' || !TAK_TRACKER_UID_RE.test(n.uid)) return null; diff --git a/src/preload/index.ts b/src/preload/index.ts index 4a02634ca..18f094d38 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -55,6 +55,8 @@ import type { TAKClientInfo, TAKContact, TAKContactsUpdate, + TAKDirectMessage, + TakDmProtocol, TAKEnrollmentRequest, TAKInboundChat, TAKNodeUpdate, @@ -1382,6 +1384,10 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('tak:setChatChannels', channels), retireChatRooms: (rooms: string[]): Promise => ipcRenderer.invoke('tak:retireChatRooms', rooms), + pushDirectMessage: (message: TAKDirectMessage): Promise => + ipcRenderer.invoke('tak:pushDirectMessage', message), + setDirectMessaging: (protocols: TakDmProtocol[]): Promise => + ipcRenderer.invoke('tak:setDirectMessaging', protocols), getStyleSettings: (): Promise => ipcRenderer.invoke('tak:getStyleSettings'), setStyleSettings: (settings: TakStyleSettings): Promise => ipcRenderer.invoke('tak:setStyleSettings', settings), diff --git a/src/renderer/components/TakChannelRelaySection.test.tsx b/src/renderer/components/TakChannelRelaySection.test.tsx index 67cc8a259..c96c8bbdd 100644 --- a/src/renderer/components/TakChannelRelaySection.test.tsx +++ b/src/renderer/components/TakChannelRelaySection.test.tsx @@ -8,7 +8,7 @@ import type { IdentityId } from '../lib/types'; import { setTakChannels, useTakChannelStore } from '../stores/takChannelStore'; import { getTakRelayPrefs, useTakRelayPrefsStore } from '../stores/takRelayPrefsStore'; import { setTakSinkActive } from '../stores/takSinkStore'; -import TakChannelRelaySection from './TakChannelRelaySection'; +import TakChannelRelaySection, { TakDirectMessagesToggle } from './TakChannelRelaySection'; const ID = 'tak-channel-relay-test'; const PUBLIC = '0123456789abcdef'; @@ -151,6 +151,35 @@ describe('TakChannelRelaySection', () => { expect(await axe(container)).toHaveNoViolations(); }); + it('turns direct messages on and off for the radio, inside its section', async () => { + render( + + + , + ); + const toggle = screen.getByLabelText('Direct messages'); + expect(toggle).not.toBeChecked(); + expect(toggle).toHaveAccessibleDescription(/last wrote to it/); + await userEvent.click(toggle); + expect(getTakRelayPrefs(ID).directMessages).toBe(true); + expect(toggle).toBeChecked(); + await userEvent.click(toggle); + expect(getTakRelayPrefs(ID).directMessages).toBe(false); + }); + + it('shows no direct message switch without a radio', () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); it("keeps a channel's settings with the channel when another takes its slot", async () => { const user = userEvent.setup(); useTakRelayPrefsStore.getState().setChannelMode(ID, 'all'); diff --git a/src/renderer/components/TakChannelRelaySection.tsx b/src/renderer/components/TakChannelRelaySection.tsx index 19c5a4865..ab8e07f2e 100644 --- a/src/renderer/components/TakChannelRelaySection.tsx +++ b/src/renderer/components/TakChannelRelaySection.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { type ReactNode, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { TAK_GEOCHAT_ROOM_MAX_LEN } from '@/shared/tak-types'; @@ -104,12 +104,15 @@ interface Props { emptyText: string; /** Settings search anchor for the section. */ anchorId: string; + /** Shown at the end of the section: the radio's direct message switch. */ + children?: ReactNode; } /** - * One radio's channels in ATAK: which become contacts (off, all, or selected ones), and for each - * served channel whether TAK devices may send to it and whether it streams to the remote server. - * Also the per-channel `!MT1` tracker opt-in when supported. Only shown while a TAK sink is up. + * One radio's messaging in ATAK: which channels become contacts (off, all, or selected ones), + * and for each served channel whether TAK devices may send to it and whether it streams to the + * remote server; the per-channel `!MT1` tracker opt-in when supported; and whether TAK devices + * may message the radio's companions. Only shown while a TAK sink is up. */ export default function TakChannelRelaySection({ identityId, @@ -118,6 +121,7 @@ export default function TakChannelRelaySection({ description, emptyText, anchorId, + children, }: Props) { const { t } = useTranslation(); const sinkActive = useTakSinkStore((s) => s.active); @@ -236,6 +240,44 @@ export default function TakChannelRelaySection({ })} )} + {children} + + ); +} + +interface DirectMessagesProps { + identityId: IdentityId | null; + /** Settings search anchor for the switch. */ + anchorId: string; +} + +/** The switch letting TAK devices message one radio's companions; shown inside its section. */ +export function TakDirectMessagesToggle({ identityId, anchorId }: DirectMessagesProps) { + const { t } = useTranslation(); + const enabled = useTakRelayPrefsStore((s) => + identityId ? (s.byIdentity[identityId]?.directMessages ?? false) : false, + ); + const setDirectMessages = useTakRelayPrefsStore((s) => s.setDirectMessages); + if (!identityId) return null; + const id = `tak-direct-messages-${identityId}`; + return ( +
+ +

+ {t('takServerPanel.directMessagesHint')} +

); } diff --git a/src/renderer/components/TakServerPanel.tsx b/src/renderer/components/TakServerPanel.tsx index 01588174f..86aade33b 100644 --- a/src/renderer/components/TakServerPanel.tsx +++ b/src/renderer/components/TakServerPanel.tsx @@ -11,7 +11,7 @@ import type { TAKSettings } from '@/shared/tak-types'; import { useTakServer } from '../hooks/useTakServer'; import type { IdentityId } from '../lib/types'; -import TakChannelRelaySection from './TakChannelRelaySection'; +import TakChannelRelaySection, { TakDirectMessagesToggle } from './TakChannelRelaySection'; import TakRemoteRelaySection from './TakRemoteRelaySection'; import TakUnitFiltersSection from './TakUnitFiltersSection'; import { INPUT_BOX_CLASS } from './ui/formClasses'; @@ -307,7 +307,12 @@ export default function TakServerPanel({ description={t('takServerPanel.messagingIntroMeshtastic')} emptyText={t('takServerPanel.channelRelayNoChannelsMeshtastic')} anchorId="tak.channelRelay.meshtastic" - /> + > + + + > + + {/* ATAK Plugin Messages from Mesh */} {capabilities?.hasAtakPlugin && ( diff --git a/src/renderer/hooks/useTakInboundChat.test.tsx b/src/renderer/hooks/useTakInboundChat.test.tsx index dc7d68e4c..95b507ff5 100644 --- a/src/renderer/hooks/useTakInboundChat.test.tsx +++ b/src/renderer/hooks/useTakInboundChat.test.tsx @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { computeChannelIdentityKey } from '@/shared/channelIdentityKey'; import type { TakChatChannel } from '@/shared/tak-types'; -import { getTakChannels, useTakChannelStore } from '../stores/takChannelStore'; +import { getTakChannels, getTakRadio, useTakChannelStore } from '../stores/takChannelStore'; import { useTakRelayPrefsStore } from '../stores/takRelayPrefsStore'; import { useTakSinkStore } from '../stores/takSinkStore'; import { @@ -15,6 +15,7 @@ import { /** The mocked IPC call the hook reports the served channels through. */ const setChatChannels = () => vi.mocked(window.electronAPI.tak.setChatChannels); +const setDirectMessaging = () => vi.mocked(window.electronAPI.tak.setDirectMessaging); const PUBLIC = Uint8Array.from('8b3387e9c5cdea6ac9e5edbaa115cd72'.match(/../g) ?? [], (b) => parseInt(b, 16), @@ -53,6 +54,7 @@ describe('useTakInboundChat', () => { beforeEach(() => { setChatChannels().mockClear(); + setDirectMessaging().mockClear(); useTakSinkStore.setState({ active: false }); useTakRelayPrefsStore.setState({ byIdentity: {} }); useTakChannelStore.setState({ channelsByIdentity: {} }); @@ -82,6 +84,34 @@ describe('useTakInboundChat', () => { expect(getTakChannels('mc').map((c) => c.index)).toEqual([0]); }); + it('records the radio TAK uses for each protocol', () => { + mount(); + expect(getTakRadio('meshcore')).toBe('mc'); + expect(getTakRadio('meshtastic')).toBeUndefined(); + act(() => { + rerender?.({ ...sources(), meshtastic: { identityId: 'mt', channelConfigs: [] } }); + }); + expect(getTakRadio('meshtastic')).toBe('mt'); + }); + + it('tells main which radios take direct messages, when a sink starts and when it changes', () => { + mount(); + expect(setDirectMessaging()).not.toHaveBeenCalled(); + act(() => { + useTakSinkStore.setState({ active: true }); + }); + expect(setDirectMessaging()).toHaveBeenLastCalledWith([]); + act(() => { + useTakRelayPrefsStore.getState().setDirectMessages('mc', true); + }); + expect(setDirectMessaging()).toHaveBeenLastCalledWith(['meshcore']); + // A change elsewhere in the settings sends nothing new. + act(() => { + useTakRelayPrefsStore.getState().setChatRemote('mc', OPS.key, true); + }); + expect(setDirectMessaging()).toHaveBeenCalledTimes(2); + }); + it("binds settings saved by slot to the radio's channels as it reports them", () => { useTakRelayPrefsStore.setState({ byIdentity: { @@ -92,6 +122,7 @@ describe('useTakInboundChat', () => { sendChannels: [], remoteChannels: [], atakNames: {}, + directMessages: false, pendingSlots: { chatChannels: [2], sendChannels: [2], diff --git a/src/renderer/hooks/useTakInboundChat.ts b/src/renderer/hooks/useTakInboundChat.ts index c0b60124f..2cf695b9d 100644 --- a/src/renderer/hooks/useTakInboundChat.ts +++ b/src/renderer/hooks/useTakInboundChat.ts @@ -1,8 +1,9 @@ import { useEffect, useMemo } from 'react'; -import type { TakChatChannel } from '@/shared/tak-types'; +import type { TakChatChannel, TakDmProtocol } from '@/shared/tak-types'; import { errLikeToLogString } from '../lib/errLikeToLogString'; +import { listTakDmProtocols } from '../lib/tak/takDirectMessages'; import { enqueueTakChatRelay } from '../lib/tak/takInboundChat'; import { sendTakTextOnMesh } from '../lib/tak/takMeshSend'; import { @@ -12,7 +13,7 @@ import { toTakChatChannels, } from '../lib/tak/takServedChannels'; import type { IdentityId } from '../lib/types'; -import { setTakChannels, type TakChannelInfo } from '../stores/takChannelStore'; +import { setTakChannels, setTakRadio, type TakChannelInfo } from '../stores/takChannelStore'; import { bindTakPendingSlots, getTakRetiredRooms, @@ -24,6 +25,45 @@ import { useTakSinkStore } from '../stores/takSinkStore'; export const TAK_SEND_ROOMS_RETRY_MS = 5_000; export const TAK_SEND_ROOMS_RETRY_MAX_MS = 60_000; +/** + * Keep main told a setting while a sink is up: `value` (JSON) is sent whenever a sink starts or + * it changes. A refused update (main's rate limit, say) would leave main with an old value, so it + * is retried, waiting longer each time, until it lands or a newer value or unmount replaces it. + */ +function useSendWhileSinkActive( + sinkActive: boolean, + valueJson: string, + send: (value: unknown) => Promise, + label: string, +): void { + useEffect(() => { + // Main keeps the setting only while a sink exists, so it is sent again whenever one starts. + if (!sinkActive) return; + const value: unknown = JSON.parse(valueJson); + let cancelled = false; + let retryTimer: ReturnType | undefined; + let delayMs = TAK_SEND_ROOMS_RETRY_MS; + const attempt = (): void => { + send(value).catch((e: unknown) => { + if (cancelled) return; + console.warn(`[TakInboundChat] ${label} failed; retrying ` + errLikeToLogString(e)); + retryTimer = setTimeout(attempt, delayMs); + delayMs = Math.min(delayMs * 2, TAK_SEND_ROOMS_RETRY_MAX_MS); + }); + }; + attempt(); + return () => { + cancelled = true; + clearTimeout(retryTimer); + }; + }, [sinkActive, valueJson, send, label]); +} + +const sendChatChannels = (value: unknown): Promise => + window.electronAPI.tak.setChatChannels(value as TakChatChannel[]); +const sendDirectMessaging = (value: unknown): Promise => + window.electronAPI.tak.setDirectMessaging(value as TakDmProtocol[]); + /** Each radio's identity and its runtime channel list, as App has them. */ export interface TakChannelSources { meshcore: { identityId: IdentityId | null; channels: readonly unknown[] }; @@ -31,10 +71,11 @@ export interface TakChannelSources { } /** - * Serves mesh channels to TAK clients. Publishes each radio's channels (with their ATAK names) - * for the TAK panel and the relay, keeps main told which channels are served, and sends what TAK - * clients write to a channel out on the mesh, one message at a time in arrival order. - * Mount once from AppContent. + * Serves mesh channels and direct messages to TAK clients. Publishes each radio's channels (with + * their ATAK names) for the TAK panel and the relay, and which radio TAK uses per protocol; keeps + * main told which channels are served and which radios take direct messages; and sends what TAK + * clients write to a channel or a companion out on the mesh, one message at a time in arrival + * order. Mount once from AppContent. */ export function useTakInboundChat(sources: TakChannelSources): void { const meshcoreId = sources.meshcore.identityId; @@ -63,6 +104,11 @@ export function useTakInboundChat(sources: TakChannelSources): void { setTakChannels(meshcoreId, meshcoreInfos); }, [meshcoreId, meshcoreInfos]); + useEffect(() => { + setTakRadio('meshcore', meshcoreId); + setTakRadio('meshtastic', meshtasticId); + }, [meshcoreId, meshtasticId]); + useEffect(() => { if (!meshtasticId) return; bindTakPendingSlots(meshtasticId, meshtasticInfos); @@ -93,27 +139,13 @@ export function useTakInboundChat(sources: TakChannelSources): void { () => JSON.stringify(toTakChatChannels(listTakServedChannels(channelsByIdentity, relayPrefs))), [channelsByIdentity, relayPrefs], ); - useEffect(() => { - // Main keeps the list only while a sink exists, so it is sent again whenever one starts. - if (!sinkActive) return; - const channels = JSON.parse(channelsJson) as TakChatChannel[]; - // A refused update (main's rate limit, say) would leave main with an old list, so it is - // retried, waiting longer each time, until it lands or a newer list or unmount replaces it. - let cancelled = false; - let retryTimer: ReturnType | undefined; - let delayMs = TAK_SEND_ROOMS_RETRY_MS; - const send = (): void => { - window.electronAPI.tak.setChatChannels(channels).catch((e: unknown) => { - if (cancelled) return; - console.warn('[TakInboundChat] setChatChannels failed; retrying ' + errLikeToLogString(e)); - retryTimer = setTimeout(send, delayMs); - delayMs = Math.min(delayMs * 2, TAK_SEND_ROOMS_RETRY_MAX_MS); - }); - }; - send(); - return () => { - cancelled = true; - clearTimeout(retryTimer); - }; - }, [sinkActive, channelsJson]); + useSendWhileSinkActive(sinkActive, channelsJson, sendChatChannels, 'setChatChannels'); + + const dmJson = useMemo(() => { + const radios: Partial> = {}; + if (meshcoreId) radios.meshcore = meshcoreId; + if (meshtasticId) radios.meshtastic = meshtasticId; + return JSON.stringify(listTakDmProtocols(radios, relayPrefs)); + }, [meshcoreId, meshtasticId, relayPrefs]); + useSendWhileSinkActive(sinkActive, dmJson, sendDirectMessaging, 'setDirectMessaging'); } diff --git a/src/renderer/hooks/useTakNodeReplicator.ts b/src/renderer/hooks/useTakNodeReplicator.ts index 0baf15587..753b32398 100644 --- a/src/renderer/hooks/useTakNodeReplicator.ts +++ b/src/renderer/hooks/useTakNodeReplicator.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { readStoredStaticGps } from '@/renderer/lib/gpsSource'; +import { canTakDirectMessage } from '@/renderer/lib/tak/takDirectMessages'; import { collectTakNodeUpdates, TAK_NODE_REFRESH_MS, @@ -53,6 +54,7 @@ function readSources(inputs: ReplicatorInputs): TakFeedSources { } : null, trackerFixes: Object.values(inputs.trackerFixes), + canDirectMessage: canTakDirectMessage, }; } diff --git a/src/renderer/lib/devElectronApiStub.ts b/src/renderer/lib/devElectronApiStub.ts index fad9b8cac..a755aa4aa 100644 --- a/src/renderer/lib/devElectronApiStub.ts +++ b/src/renderer/lib/devElectronApiStub.ts @@ -401,6 +401,8 @@ export function createDevElectronApiStub(): typeof window.electronAPI { pushChatMessage: async () => {}, setChatChannels: async () => {}, retireChatRooms: async () => {}, + pushDirectMessage: async () => {}, + setDirectMessaging: async () => {}, onInboundChat: noopUnsub, }, bleCoexistence: { diff --git a/src/renderer/lib/ingest/meshcoreIngest.test.ts b/src/renderer/lib/ingest/meshcoreIngest.test.ts index 3095d7a2b..ec363278c 100644 --- a/src/renderer/lib/ingest/meshcoreIngest.test.ts +++ b/src/renderer/lib/ingest/meshcoreIngest.test.ts @@ -3,7 +3,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { addIdentity } from '../../stores/identityStore'; import { upsertMessage, useMessageStore } from '../../stores/messageStore'; import { useNodeStore } from '../../stores/nodeStore'; -import { getTakChannels, setTakChannels, useTakChannelStore } from '../../stores/takChannelStore'; +import { + getTakChannels, + setTakChannels, + setTakRadio, + useTakChannelStore, +} from '../../stores/takChannelStore'; import { useTakRelayPrefsStore } from '../../stores/takRelayPrefsStore'; import { setTakSinkActive } from '../../stores/takSinkStore'; import { clearTakTrackerFixes, useTakTrackerStore } from '../../stores/takTrackerStore'; @@ -16,6 +21,7 @@ import { import { getNodeStatus } from '../nodeStatus'; import { meshcoreProtocol } from '../protocols/MeshCoreProtocol'; import { messageRecordToChatMessage } from '../storeRecordAdapters'; +import { resetTakRelayedDirectMessages } from '../tak/takDirectMessages'; import { attachMeshcoreIngest, meshcoreIngestHandleTextMessage } from './meshcoreIngest'; const ID = 'meshcore-ingest-test'; @@ -789,4 +795,47 @@ describe('meshcore ingest TAK channel relay', () => { channelMessage('Bob: on scene'); expect(pushChatMessage).not.toHaveBeenCalled(); }); + + describe('direct messages', () => { + const pushDirectMessage = vi.fn().mockResolvedValue(undefined); + const PEER = 0xabcd; + + /** A DM as PacketRouter stores it before the ingest listener runs. */ + function directMessage(payload: string, timestamp = 1_700_000_500_000) { + const id = `${PEER}:${timestamp / 1000}`; + const record = { id, from: PEER, to: 0, payload, channelIndex: -1, timestamp }; + upsertMessage(TAK_ID, { ...record, senderName: 'Alice' }); + meshcoreIngestHandleTextMessage(TAK_ID, { type: 'text_message', payload: record }); + } + + beforeEach(() => { + vi.spyOn(window.electronAPI.tak, 'pushDirectMessage').mockImplementation(pushDirectMessage); + pushDirectMessage.mockClear(); + resetTakRelayedDirectMessages(); + setTakRadio('meshcore', TAK_ID); + }); + + afterEach(() => { + setTakRadio('meshcore', null); + }); + + it('hands a heard DM to TAK clients when direct messages are on, once', () => { + directMessage('meet at the gate'); + expect(pushDirectMessage).not.toHaveBeenCalled(); + + useTakRelayPrefsStore.getState().setDirectMessages(TAK_ID, true); + directMessage('meet at the gate', 1_700_000_600_000); + // The sender did not hear the ack and sent it again. + directMessage('meet at the gate', 1_700_000_600_000); + expect(pushDirectMessage).toHaveBeenCalledTimes(1); + expect(pushDirectMessage).toHaveBeenCalledWith({ + protocol: 'meshcore', + nodeId: PEER, + senderCallsign: 'Alice', + text: 'meet at the gate', + timeMs: 1_700_000_600_000, + }); + expect(pushChatMessage).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/renderer/lib/ingest/meshcoreIngest.ts b/src/renderer/lib/ingest/meshcoreIngest.ts index 1fce3a8ee..ba884c230 100644 --- a/src/renderer/lib/ingest/meshcoreIngest.ts +++ b/src/renderer/lib/ingest/meshcoreIngest.ts @@ -54,6 +54,7 @@ import { import { effectiveMessageTimestampMs } from '../nodeStatus'; import type { DomainEvent } from '../protocols/Protocol'; import { relayMeshcoreChannelToTak } from '../tak/takChannelRelay'; +import { relayDirectMessageToTak } from '../tak/takDirectMessages'; import type { ChatMessage, IdentityId } from '../types'; export interface MeshcoreIngestOptions { @@ -362,6 +363,16 @@ function handleTextMessage( : {}), }); } + // PacketRouter has stored a DM before this runs, so `inserted` cannot tell a first copy from + // a retry; the TAK relay drops retried copies itself. + if (isDm && !isEcho && mayBumpDmSender && senderId > 0) { + relayDirectMessageToTak(identityId, 'meshcore', { + nodeId: senderId, + senderName: displayName, + text: stored.payload, + timestampMs: wireTimestampMs, + }); + } if (!isEcho && mayBumpDmSender && senderId > 0) { bumpMeshcoreChatSenderLastHeard(identityId, senderId, { timestampMs: wireTimestampMs, diff --git a/src/renderer/lib/settingsSearchEntries/tak.ts b/src/renderer/lib/settingsSearchEntries/tak.ts index ea1732e7c..c66c44ef9 100644 --- a/src/renderer/lib/settingsSearchEntries/tak.ts +++ b/src/renderer/lib/settingsSearchEntries/tak.ts @@ -186,6 +186,20 @@ const takEntries: readonly SettingSearchEntry[] = [ 'mqtt', ], }, + { + id: 'tak.directMessages.meshtastic', + slot: 'TAK', + labelKey: 'takServerPanel.directMessages', + sectionKey: 'takServerPanel.messagingTitleMeshtastic', + keywords: ['tak', 'atak', 'meshtastic', 'dm', 'direct message', 'companion', 'contact', 'chat'], + }, + { + id: 'tak.directMessages.meshcore', + slot: 'TAK', + labelKey: 'takServerPanel.directMessages', + sectionKey: 'takServerPanel.messagingTitleMeshcore', + keywords: ['tak', 'atak', 'meshcore', 'dm', 'direct message', 'companion', 'contact', 'chat'], + }, ]; export const takSurface: SettingsSearchSurface = { diff --git a/src/renderer/lib/tak/takChannelRelay.test.ts b/src/renderer/lib/tak/takChannelRelay.test.ts index a1bcbe2d1..1aa1baa62 100644 --- a/src/renderer/lib/tak/takChannelRelay.test.ts +++ b/src/renderer/lib/tak/takChannelRelay.test.ts @@ -3,12 +3,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MESHTASTIC_BROADCAST_NODE_NUM } from '@/shared/nodeNameUtils'; import { TAK_GEOCHAT_TEXT_MAX_LEN } from '@/shared/tak-types'; +import { setConnection, useConnectionStore } from '../../stores/connectionStore'; import { useNodeStore } from '../../stores/nodeStore'; -import { setTakChannels, useTakChannelStore } from '../../stores/takChannelStore'; +import { setTakChannels, setTakRadio, useTakChannelStore } from '../../stores/takChannelStore'; import { useTakRelayPrefsStore } from '../../stores/takRelayPrefsStore'; import { setTakSinkActive } from '../../stores/takSinkStore'; import type { ChatMessage } from '../types'; import { relayMeshtasticChatToTak } from './takChannelRelay'; +import { resetTakRelayedDirectMessages } from './takDirectMessages'; import { rememberTakSend, resetTakEchoForTests } from './takEchoGuard'; const ID = 'tak-channel-relay-meshtastic'; @@ -106,6 +108,33 @@ describe('relayMeshtasticChatToTak', () => { expect(pushChatMessage).not.toHaveBeenCalled(); }); + it('hands a direct message to this radio on as one, when direct messages are on', () => { + const pushDirectMessage = vi.fn().mockResolvedValue(undefined); + vi.spyOn(window.electronAPI.tak, 'pushDirectMessage').mockImplementation(pushDirectMessage); + resetTakRelayedDirectMessages(); + setConnection(ID, { myNodeNum: 0x0badf00d }); + setTakRadio('meshtastic', ID); + const dm = chat({ to: 0x0badf00d, payload: 'just you' }); + relayMeshtasticChatToTak(ID, dm); + expect(pushDirectMessage).not.toHaveBeenCalled(); + + useTakRelayPrefsStore.getState().setDirectMessages(ID, true); + relayMeshtasticChatToTak(ID, dm); + relayMeshtasticChatToTak(ID, chat({ to: 0x0badf00d, sender_id: 0x0badf00d })); + relayMeshtasticChatToTak(ID, chat({ to: 0x11111111 })); + expect(pushDirectMessage).toHaveBeenCalledTimes(1); + expect(pushDirectMessage).toHaveBeenCalledWith({ + protocol: 'meshtastic', + nodeId: SENDER, + senderCallsign: 'Bob', + text: 'just you', + timeMs: NOW, + }); + expect(pushChatMessage).not.toHaveBeenCalled(); + setTakRadio('meshtastic', null); + useConnectionStore.setState({ connections: {} }); + }); + it('hands on every channel when the radio serves all of them', () => { useTakRelayPrefsStore.getState().setChannelMode(ID, 'all'); relayMeshtasticChatToTak(ID, chat({ channel: 2 })); diff --git a/src/renderer/lib/tak/takChannelRelay.ts b/src/renderer/lib/tak/takChannelRelay.ts index 1570a7439..289f6fffb 100644 --- a/src/renderer/lib/tak/takChannelRelay.ts +++ b/src/renderer/lib/tak/takChannelRelay.ts @@ -1,6 +1,7 @@ import { isMeshtasticBroadcastNodeNum } from '@/shared/nodeNameUtils'; import { TAK_GEOCHAT_CALLSIGN_MAX_LEN, TAK_GEOCHAT_TEXT_MAX_LEN } from '@/shared/tak-types'; +import { getConnection } from '../../stores/connectionStore'; import { type NodeRecord, useNodeStore } from '../../stores/nodeStore'; import { getTakRelayPrefs } from '../../stores/takRelayPrefsStore'; import { isTakSinkActive } from '../../stores/takSinkStore'; @@ -8,6 +9,7 @@ import { recordTakTrackerFix } from '../../stores/takTrackerStore'; import { parseMt1Payload } from '../meshcore/mt1Tracker'; import { effectiveMessageTimestampMs } from '../nodeStatus'; import type { ChatMessage, IdentityId } from '../types'; +import { relayDirectMessageToTak } from './takDirectMessages'; import { consumeTakEcho } from './takEchoGuard'; import { pushTakGeochat } from './takGeochatPush'; import { findServedChannel } from './takServedChannels'; @@ -65,13 +67,24 @@ export function relayMeshcoreChannelToTak(identityId: IdentityId, msg: TakChanne } /** - * Hand one newly heard Meshtastic channel message (RF or MQTT) to TAK clients, if its channel is - * served. Skips DMs, reactions and history replay. This direction never transmits on RF. + * Hand one newly heard Meshtastic message (RF or MQTT) to TAK clients: a channel message if its + * channel is served, a direct message to this radio if direct messages are on for it. Skips + * reactions, history replay and DMs between other nodes. This direction never transmits on RF. */ export function relayMeshtasticChatToTak(identityId: IdentityId, chat: ChatMessage): void { if (!isTakSinkActive()) return; if (chat.emoji || chat.isHistory) return; - if (chat.to != null && !isMeshtasticBroadcastNodeNum(chat.to)) return; + if (chat.to != null && !isMeshtasticBroadcastNodeNum(chat.to)) { + const myNodeNum = getConnection(identityId)?.myNodeNum ?? 0; + if (myNodeNum <= 0 || chat.to !== myNodeNum || chat.sender_id === myNodeNum) return; + relayDirectMessageToTak(identityId, 'meshtastic', { + nodeId: chat.sender_id, + senderName: chat.sender_name, + text: chat.payload, + timestampMs: effectiveMessageTimestampMs(chat.timestamp), + }); + return; + } const identityNodes = useNodeStore.getState().nodes[identityId] as Record | undefined; const sender = identityNodes?.[chat.sender_id]; diff --git a/src/renderer/lib/tak/takDirectMessages.test.ts b/src/renderer/lib/tak/takDirectMessages.test.ts new file mode 100644 index 000000000..6035b5787 --- /dev/null +++ b/src/renderer/lib/tak/takDirectMessages.test.ts @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MESHTASTIC_BROADCAST_NODE_NUM } from '@/shared/nodeNameUtils'; + +import { setConnection, useConnectionStore } from '../../stores/connectionStore'; +import { useNodeStore } from '../../stores/nodeStore'; +import { setTakRadio, useTakChannelStore } from '../../stores/takChannelStore'; +import { getTakRelayPrefs, useTakRelayPrefsStore } from '../../stores/takRelayPrefsStore'; +import { setTakSinkActive } from '../../stores/takSinkStore'; +import type { MeshNode } from '../types'; +import { + canTakDirectMessage, + listTakDmProtocols, + relayDirectMessageToTak, + resetTakRelayedDirectMessages, +} from './takDirectMessages'; + +const MC = 'tak-dm-meshcore'; +const MT = 'tak-dm-meshtastic'; +const NOW = Date.parse('2026-10-16T12:00:00Z'); + +function node(overrides: Partial = {}): MeshNode { + return { + node_id: 123, + long_name: 'Alice', + short_name: '', + hw_model: 'Chat', + snr: 0, + battery: 0, + last_heard: 0, + latitude: null, + longitude: null, + public_key_hex: 'ab'.repeat(32), + ...overrides, + }; +} + +beforeEach(() => { + useTakChannelStore.setState({ channelsByIdentity: {}, radioByProtocol: {} }); + useTakRelayPrefsStore.setState({ byIdentity: {} }); + useConnectionStore.setState({ connections: {} }); + setTakRadio('meshcore', MC); + setTakRadio('meshtastic', MT); + setConnection(MC, { myNodeNum: 1 }); + setConnection(MT, { myNodeNum: 2 }); +}); + +afterEach(() => { + useTakChannelStore.setState({ channelsByIdentity: {}, radioByProtocol: {} }); + useTakRelayPrefsStore.setState({ byIdentity: {} }); + useConnectionStore.setState({ connections: {} }); +}); + +describe('canTakDirectMessage', () => { + it('allows a MeshCore companion whose key this app holds', () => { + expect(canTakDirectMessage(node(), 'meshcore')).toBe(true); + }); + + it.each([ + ['without a key', node({ public_key_hex: ' ' })], + ['a repeater', node({ hw_model: 'Repeater' })], + ['a room server', node({ hw_model: 'Room' })], + ['this radio itself', node({ node_id: 1 })], + ])('refuses a MeshCore node %s', (_label, n) => { + expect(canTakDirectMessage(n, 'meshcore')).toBe(false); + }); + + it('allows any other Meshtastic node, but not this radio or the broadcast address', () => { + const mt = node({ public_key_hex: undefined, hw_model: 'TBEAM' }); + expect(canTakDirectMessage({ ...mt, node_id: 456 }, 'meshtastic')).toBe(true); + expect(canTakDirectMessage({ ...mt, node_id: 2 }, 'meshtastic')).toBe(false); + expect( + canTakDirectMessage({ ...mt, node_id: MESHTASTIC_BROADCAST_NODE_NUM }, 'meshtastic'), + ).toBe(false); + }); + + it('refuses everything without a TAK radio for the protocol, and Reticulum for now', () => { + expect(canTakDirectMessage(node(), 'reticulum')).toBe(false); + setTakRadio('meshcore', null); + expect(canTakDirectMessage(node(), 'meshcore')).toBe(false); + }); +}); + +describe('listTakDmProtocols', () => { + it('lists the protocols whose TAK radio has direct messages on', () => { + const prefs = { + [MC]: { ...getTakRelayPrefs(MC), directMessages: true }, + [MT]: { ...getTakRelayPrefs(MT), directMessages: false }, + }; + expect(listTakDmProtocols({ meshcore: MC, meshtastic: MT }, prefs)).toEqual(['meshcore']); + expect(listTakDmProtocols({ meshtastic: MT }, prefs)).toEqual([]); + }); +}); + +describe('relayDirectMessageToTak', () => { + const pushDirectMessage = vi.fn().mockResolvedValue(undefined); + const heard = { + nodeId: 123, + senderName: ' Alice ', + text: ' meet at the gate ', + timestampMs: NOW, + }; + + beforeEach(() => { + vi.spyOn(window.electronAPI.tak, 'pushDirectMessage').mockImplementation(pushDirectMessage); + pushDirectMessage.mockClear(); + resetTakRelayedDirectMessages(); + setTakSinkActive(true); + useTakRelayPrefsStore.getState().setDirectMessages(MC, true); + }); + + afterEach(() => { + setTakSinkActive(false); + vi.restoreAllMocks(); + }); + + it('hands a heard direct message to TAK, once however often it is retried', () => { + relayDirectMessageToTak(MC, 'meshcore', heard); + relayDirectMessageToTak(MC, 'meshcore', heard); + expect(pushDirectMessage).toHaveBeenCalledTimes(1); + expect(pushDirectMessage).toHaveBeenCalledWith({ + protocol: 'meshcore', + nodeId: 123, + senderCallsign: 'Alice', + text: 'meet at the gate', + timeMs: NOW, + }); + // The same words sent again later are a new message. + relayDirectMessageToTak(MC, 'meshcore', { ...heard, timestampMs: NOW + 60_000 }); + expect(pushDirectMessage).toHaveBeenCalledTimes(2); + }); + + it('names an unnamed sender by its node id', () => { + relayDirectMessageToTak(MC, 'meshcore', { ...heard, senderName: ' ' }); + expect(pushDirectMessage.mock.calls[0]?.[0]).toMatchObject({ senderCallsign: '123' }); + }); + + it.each([ + [ + 'with no TAK sink', + () => { + setTakSinkActive(false); + }, + ], + [ + 'with direct messages off', + () => { + useTakRelayPrefsStore.getState().setDirectMessages(MC, false); + }, + ], + [ + 'to a radio TAK does not use', + () => { + setTakRadio('meshcore', 'another-radio'); + }, + ], + ])('hands nothing on %s', (_label, arrange) => { + arrange(); + relayDirectMessageToTak(MC, 'meshcore', heard); + expect(pushDirectMessage).not.toHaveBeenCalled(); + }); + + it('hands nothing on from a MeshCore repeater or room server, whose DMs are command replies', () => { + useNodeStore.setState({ nodes: { [MC]: { 123: { nodeId: 123, hwModel: 'Repeater' } } } }); + relayDirectMessageToTak(MC, 'meshcore', heard); + expect(pushDirectMessage).not.toHaveBeenCalled(); + useNodeStore.setState({ nodes: {} }); + }); + + it('hands nothing on for an empty message', () => { + relayDirectMessageToTak(MC, 'meshcore', { ...heard, text: ' ' }); + expect(pushDirectMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/lib/tak/takDirectMessages.ts b/src/renderer/lib/tak/takDirectMessages.ts new file mode 100644 index 000000000..7be20b496 --- /dev/null +++ b/src/renderer/lib/tak/takDirectMessages.ts @@ -0,0 +1,114 @@ +import type { MeshProtocol } from '@/shared/meshProtocol'; +import { isMeshtasticBroadcastNodeNum } from '@/shared/nodeNameUtils'; +import { + isTakDmProtocol, + TAK_DM_PROTOCOLS, + TAK_GEOCHAT_CALLSIGN_MAX_LEN, + TAK_GEOCHAT_TEXT_MAX_LEN, + type TakDmProtocol, +} from '@/shared/tak-types'; + +import { getConnection } from '../../stores/connectionStore'; +import { type NodeRecord, useNodeStore } from '../../stores/nodeStore'; +import { getTakRadio } from '../../stores/takChannelStore'; +import { getTakRelayPrefs, type TakRelayIdentityPrefs } from '../../stores/takRelayPrefsStore'; +import { isTakSinkActive } from '../../stores/takSinkStore'; +import { errLikeToLogString } from '../errLikeToLogString'; +import { isMeshcoreDmExcludedHwModel } from '../meshcoreUtils'; +import type { IdentityId, MeshNode } from '../types'; + +/** + * Whether this app can send `node` a direct message on the radio TAK uses for `protocol`, by + * the rule Chat and Rooms use: any other Meshtastic node, or a MeshCore node that is not a + * repeater, room server or sensor and whose public key this app holds. Main still checks the + * node is a companion and that direct messages are on for the radio. + */ +export function canTakDirectMessage(node: MeshNode, protocol: MeshProtocol): boolean { + if (!isTakDmProtocol(protocol) || node.node_id <= 0) return false; + const identityId = getTakRadio(protocol); + if (!identityId || node.node_id === getConnection(identityId)?.myNodeNum) return false; + if (protocol === 'meshtastic') return !isMeshtasticBroadcastNodeNum(node.node_id); + return !isMeshcoreDmExcludedHwModel(node.hw_model) && Boolean(node.public_key_hex?.trim()); +} + +/** The protocols whose TAK radio has direct messages on, for main. */ +export function listTakDmProtocols( + radioByProtocol: Partial>, + prefsByIdentity: Record, +): TakDmProtocol[] { + return TAK_DM_PROTOCOLS.filter((p) => { + const identityId = radioByProtocol[p]; + return identityId !== undefined && prefsByIdentity[identityId]?.directMessages === true; + }); +} + +/** A retried copy of a direct message arrives within this long of the first. */ +const RELAYED_DM_TTL_MS = 10 * 60 * 1000; +const RELAYED_DM_MAX = 256; +/** Direct messages already handed to TAK, by sender, send time and text, with when. */ +const relayedDms = new Map(); + +/** + * True the first time a direct message is seen within the retry window. A sender that does not + * hear its ack sends the same message again, with the same send time and text. + */ +function isFirstCopy(key: string, nowMs: number): boolean { + for (const [k, at] of relayedDms) { + if (nowMs - at < RELAYED_DM_TTL_MS && relayedDms.size < RELAYED_DM_MAX) break; + relayedDms.delete(k); + } + if (relayedDms.has(key)) return false; + relayedDms.set(key, nowMs); + return true; +} + +/** Forget which direct messages were relayed; for tests. */ +export function resetTakRelayedDirectMessages(): void { + relayedDms.clear(); +} + +export interface TakHeardDirectMessage { + /** The companion that sent it. */ + nodeId: number; + /** Its name as heard. */ + senderName: string; + text: string; + timestampMs: number; +} + +/** + * Hand one heard direct message to TAK clients, when it reached the radio TAK uses for + * `protocol` and that radio has direct messages on, unless it is a retried copy of one already + * handed over. Main sends it to the TAK device that last wrote to the companion, or to all of + * them. This direction never transmits on RF. + */ +export function relayDirectMessageToTak( + identityId: IdentityId, + protocol: TakDmProtocol, + msg: TakHeardDirectMessage, +): void { + if (!isTakSinkActive() || getTakRadio(protocol) !== identityId) return; + if (!getTakRelayPrefs(identityId).directMessages) return; + // A repeater's or room server's reply to a login or command is not a conversation. + if (protocol === 'meshcore') { + const nodes = useNodeStore.getState().nodes[identityId] as + Record | undefined; + if (isMeshcoreDmExcludedHwModel(nodes?.[msg.nodeId]?.hwModel)) return; + } + const text = msg.text.trim().slice(0, TAK_GEOCHAT_TEXT_MAX_LEN); + if (!text || msg.nodeId <= 0) return; + if (!isFirstCopy(`${protocol}:${msg.nodeId}:${msg.timestampMs}:${text}`, Date.now())) return; + const senderCallsign = + msg.senderName.trim().slice(0, TAK_GEOCHAT_CALLSIGN_MAX_LEN) || String(msg.nodeId); + window.electronAPI.tak + .pushDirectMessage({ + protocol, + nodeId: msg.nodeId, + senderCallsign, + text, + timeMs: msg.timestampMs, + }) + .catch((e: unknown) => { + console.warn('[TakDirectMessages] pushDirectMessage failed ' + errLikeToLogString(e)); + }); +} diff --git a/src/renderer/lib/tak/takInboundChat.test.ts b/src/renderer/lib/tak/takInboundChat.test.ts index a581ec3db..17f552aba 100644 --- a/src/renderer/lib/tak/takInboundChat.test.ts +++ b/src/renderer/lib/tak/takInboundChat.test.ts @@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { TAKInboundChat } from '@/shared/tak-types'; -import { getTakChannels, setTakChannels, useTakChannelStore } from '../../stores/takChannelStore'; +import { + getTakChannels, + setTakChannels, + setTakRadio, + useTakChannelStore, +} from '../../stores/takChannelStore'; import { useTakRelayPrefsStore } from '../../stores/takRelayPrefsStore'; import { consumeTakEcho, resetTakEchoForTests } from './takEchoGuard'; import { @@ -22,7 +27,7 @@ const OTHER = 'fedcba9876543210'; /** An inbound TAK chat message with overridable fields. */ function message(overrides: Partial = {}): TAKInboundChat { return { - channelKey: OPS, + target: { kind: 'channel', channelKey: OPS }, senderCallsign: 'VIPER', text: 'on scene', clientId: 'client-1', @@ -31,6 +36,13 @@ function message(overrides: Partial = {}): TAKInboundChat { }; } +const channel = (channelKey: string) => ({ kind: 'channel' as const, channelKey }); +const direct = (protocol: 'meshcore' | 'meshtastic', nodeId: number) => ({ + kind: 'direct' as const, + protocol, + nodeId, +}); + /** Serve one channel of a radio to TAK, with sending allowed or not. */ function serve( identityId: string, @@ -52,12 +64,12 @@ function serve( beforeEach(() => { resetTakEchoForTests(); useTakRelayPrefsStore.setState({ byIdentity: {} }); - useTakChannelStore.setState({ channelsByIdentity: {} }); + useTakChannelStore.setState({ channelsByIdentity: {}, radioByProtocol: {} }); }); afterEach(() => { useTakRelayPrefsStore.setState({ byIdentity: {} }); - useTakChannelStore.setState({ channelsByIdentity: {} }); + useTakChannelStore.setState({ channelsByIdentity: {}, radioByProtocol: {} }); }); describe('formatTakMeshText', () => { @@ -85,7 +97,7 @@ describe('findTakSendTargets', () => { serve('mc', 'meshcore', 3, OTHER, true); serve('mc2', 'meshcore', 5, OPS, true); serve('mt', 'meshtastic', 0, OPS, false); - expect(findTakSendTargets(OPS)).toEqual([ + expect(findTakSendTargets(channel(OPS))).toEqual([ { identityId: 'mc', protocol: 'meshcore', channelIndex: 2 }, { identityId: 'mc2', protocol: 'meshcore', channelIndex: 5 }, ]); @@ -97,9 +109,9 @@ describe('findTakSendTargets', () => { { protocol: 'meshtastic', index: 1, key: OTHER, name: 'x', atakName: '#MT x' }, ]); useTakRelayPrefsStore.getState().setChatSend('mt', OTHER, true); - expect(findTakSendTargets(OPS)).toEqual([]); - expect(findTakSendTargets(OTHER)).toEqual([]); - expect(findTakSendTargets('0000000000000000')).toEqual([]); + expect(findTakSendTargets(channel(OPS))).toEqual([]); + expect(findTakSendTargets(channel(OTHER))).toEqual([]); + expect(findTakSendTargets(channel('0000000000000000'))).toEqual([]); }); }); @@ -108,7 +120,7 @@ describe('relayTakChatToMesh', () => { serve('mc', 'meshcore', 2, OPS, false); const send = vi.fn().mockResolvedValue(undefined); expect(await relayTakChatToMesh(message(), send, NOW)).toBe(0); - expect(await relayTakChatToMesh(message({ channelKey: OTHER }), send, NOW)).toBe(0); + expect(await relayTakChatToMesh(message({ target: channel(OTHER) }), send, NOW)).toBe(0); expect(send).not.toHaveBeenCalled(); }); @@ -200,3 +212,32 @@ describe('enqueueTakChatRelay', () => { warn.mockRestore(); }); }); + +describe('direct messages from TAK', () => { + it("goes to the companion on the protocol's TAK radio when it has direct messages on", () => { + setTakRadio('meshcore', 'mc'); + setTakRadio('meshtastic', 'mt'); + useTakRelayPrefsStore.getState().setDirectMessages('mc', true); + expect(findTakSendTargets(direct('meshcore', 123))).toEqual([ + { identityId: 'mc', protocol: 'meshcore', channelIndex: 0, destination: 123 }, + ]); + // Direct messages are off for the Meshtastic radio, and there may be no radio at all. + expect(findTakSendTargets(direct('meshtastic', 456))).toEqual([]); + setTakRadio('meshcore', null); + expect(findTakSendTargets(direct('meshcore', 123))).toEqual([]); + }); + + it('sends the prefixed text as a direct message, with no echo to wait for', async () => { + setTakRadio('meshtastic', 'mt'); + useTakRelayPrefsStore.getState().setDirectMessages('mt', true); + const send = vi.fn().mockResolvedValue(undefined); + expect( + await relayTakChatToMesh(message({ target: direct('meshtastic', 456) }), send, NOW), + ).toBe(1); + expect(send).toHaveBeenCalledWith( + { identityId: 'mt', protocol: 'meshtastic', channelIndex: 0, destination: 456 }, + '[VIPER] on scene', + ); + expect(consumeTakEcho('mt', 0, '[VIPER] on scene', NOW + 1000)).toBe(false); + }); +}); diff --git a/src/renderer/lib/tak/takInboundChat.ts b/src/renderer/lib/tak/takInboundChat.ts index 001c5e099..b709c6e9a 100644 --- a/src/renderer/lib/tak/takInboundChat.ts +++ b/src/renderer/lib/tak/takInboundChat.ts @@ -1,5 +1,7 @@ -import type { TAKInboundChat } from '@/shared/tak-types'; +import type { TakChatTarget, TAKInboundChat } from '@/shared/tak-types'; +import { getTakRadio } from '../../stores/takChannelStore'; +import { getTakRelayPrefs } from '../../stores/takRelayPrefsStore'; import { errLikeToLogString } from '../errLikeToLogString'; import type { IdentityId } from '../types'; import { consumeTakEcho, rememberTakSend } from './takEchoGuard'; @@ -52,12 +54,23 @@ export interface TakSendTarget { identityId: IdentityId; protocol: 'meshtastic' | 'meshcore'; channelIndex: number; + /** The companion a direct message goes to; unset for a channel message. */ + destination?: number; } -/** Served channels with `channelKey` that TAK clients may send to, on every radio serving it. */ -export function findTakSendTargets(channelKey: string): TakSendTarget[] { +/** + * Where a TAK client's message goes out: for a channel, every radio serving it with sending + * allowed; for a companion, the radio TAK uses for its protocol, if that radio has direct + * messages on. + */ +export function findTakSendTargets(target: TakChatTarget): TakSendTarget[] { + if (target.kind === 'direct') { + const identityId = getTakRadio(target.protocol); + if (!identityId || !getTakRelayPrefs(identityId).directMessages) return []; + return [{ identityId, protocol: target.protocol, channelIndex: 0, destination: target.nodeId }]; + } return listTakServedChannels() - .filter((c) => c.key === channelKey && c.send) + .filter((c) => c.key === target.channelKey && c.send) .map((c) => ({ identityId: c.identityId, protocol: c.protocol, channelIndex: c.index })); } @@ -83,26 +96,29 @@ function withSendTimeout(send: Promise): Promise { } /** - * Transmit one TAK client's message on the served channel it was sent to, on every radio serving - * that channel with sending allowed. Does nothing when none allows it. Returns how many radios it - * was sent on; a failed send is logged and does not stop the others. + * Transmit one TAK client's message: on the served channel it was sent to, on every radio + * serving that channel with sending allowed, or as a direct message to the companion it was + * sent to. Does nothing when nothing allows it. Returns how many radios it was sent on; a + * failed send is logged and does not stop the others. */ export async function relayTakChatToMesh( message: TAKInboundChat, sendFn: TakMeshSendFn, nowMs: number = Date.now(), ): Promise { - const targets = findTakSendTargets(message.channelKey); + const targets = findTakSendTargets(message.target); if (targets.length === 0) return 0; const text = formatTakMeshText(message.senderCallsign, message.text); let sent = 0; for (const target of targets) { - rememberTakSend(target.identityId, target.channelIndex, text, nowMs); + // Only a channel message is heard back off the mesh; a direct message has no echo. + const onChannel = target.destination === undefined; + if (onChannel) rememberTakSend(target.identityId, target.channelIndex, text, nowMs); try { await withSendTimeout(sendFn(target, text)); sent++; } catch (e) { - consumeTakEcho(target.identityId, target.channelIndex, text, nowMs); + if (onChannel) consumeTakEcho(target.identityId, target.channelIndex, text, nowMs); console.warn('[TakInboundChat] mesh send failed ' + errLikeToLogString(e)); } } diff --git a/src/renderer/lib/tak/takMeshSend.test.ts b/src/renderer/lib/tak/takMeshSend.test.ts index 507d69aef..684daa926 100644 --- a/src/renderer/lib/tak/takMeshSend.test.ts +++ b/src/renderer/lib/tak/takMeshSend.test.ts @@ -126,6 +126,28 @@ describe('sendTakTextOnMesh', () => { ]); }); + it('sends a direct message through the Chat path without waiting for its ack', async () => { + // The companion has not answered yet: a channel send would still be waiting here. + const sendSpy = vi + .spyOn(meshcoreProtocol, 'sendMessage') + .mockImplementation(() => new Promise(() => {})); + vi.mocked(connectionDriver.getHandle).mockReturnValue({ kind: 'rf' }); + addMeshcoreIdentity(); + + await sendTakTextOnMesh( + { identityId: ID_MC, protocol: 'meshcore', channelIndex: 0, destination: 123 }, + '[VIPER] meet at the gate', + ); + + expect(sendSpy).toHaveBeenCalledWith( + { kind: 'rf' }, + expect.objectContaining({ text: '[VIPER] meet at the gate', destination: 123 }), + ); + expect(rows(ID_MC)).toEqual([ + expect.objectContaining({ to: 123, channelIndex: -1, status: 'sending' }), + ]); + }); + it('rejects when the MeshCore send fails, leaving the message failed in Chat', async () => { mockConsoleWarn(); vi.spyOn(meshcoreProtocol, 'sendMessage').mockRejectedValue(new Error('rf down')); diff --git a/src/renderer/lib/tak/takMeshSend.ts b/src/renderer/lib/tak/takMeshSend.ts index f20d40079..be6d193ba 100644 --- a/src/renderer/lib/tak/takMeshSend.ts +++ b/src/renderer/lib/tak/takMeshSend.ts @@ -58,18 +58,22 @@ export function waitForOutboundSettled( } /** - * Send a TAK client's channel text on one identity through the same path as Chat, so it shows - * in Chat, is stored, and gets "Heard by" on MeshCore. Resolves once the message is on its way: - * for MeshCore when the companion accepts it, for Meshtastic once it is queued (its ack arrives - * later, as for any Chat message). Rejects when nothing was sent or the send failed. + * Send a TAK client's text on one identity through the same path as Chat, so it shows in Chat, + * is stored, and gets "Heard by" on MeshCore. Resolves once the message is on its way: for a + * MeshCore channel when the companion accepts it; for Meshtastic, and for any direct message, + * once it is started (its ack, and any automatic resend, follow as for a message typed in + * Chat). Rejects when nothing was sent or the send failed. */ export const sendTakTextOnMesh: TakMeshSendFn = async (target, text) => { const storeId = sendChatOnIdentity(target.identityId, { text, channelIndex: target.channelIndex, + ...(target.destination !== undefined ? { destination: target.destination } : {}), }); if (storeId == null) throw new Error(`${target.protocol} send was not started`); - if (target.protocol === 'meshtastic') return; + // A MeshCore DM stays `sending` until its ack, which can take longer than the send queue + // should wait; delivery is tracked by the message store. + if (target.protocol === 'meshtastic' || target.destination !== undefined) return; const outcome = await waitForOutboundSettled(target.identityId, storeId); if (outcome === 'failed') throw new Error('MeshCore send failed'); if (outcome === 'timeout') throw new Error('mesh send timed out'); diff --git a/src/renderer/lib/tak/takServedChannels.test.ts b/src/renderer/lib/tak/takServedChannels.test.ts index b05de9946..57f6dac13 100644 --- a/src/renderer/lib/tak/takServedChannels.test.ts +++ b/src/renderer/lib/tak/takServedChannels.test.ts @@ -98,6 +98,7 @@ describe('served channels', () => { sendChannels: [opsKey], remoteChannels: [publicKey], atakNames: {}, + directMessages: false, }; it('serves nothing when off, everything when all, and the picked ones when selected', () => { diff --git a/src/renderer/lib/takNodeFeed.test.ts b/src/renderer/lib/takNodeFeed.test.ts index 38e156c35..90f023212 100644 --- a/src/renderer/lib/takNodeFeed.test.ts +++ b/src/renderer/lib/takNodeFeed.test.ts @@ -179,6 +179,28 @@ describe('collectTakNodeUpdates', () => { expect(updates.at(-1)).toMatchObject({ long_name: 'Base', last_heard: NOW_SEC }); }); + it('marks the nodes this app can send a direct message to', () => { + const updates = collectTakNodeUpdates({ + ...sources({ + nodesByProtocol: { + meshtastic: new Map([[1, node({ node_id: 1 })]]), + meshcore: new Map([[2, node({ node_id: 2 })]]), + reticulum: new Map(), + }, + }), + canDirectMessage: (n, protocol) => protocol === 'meshcore' && n.node_id === 2, + }); + expect(updates.map((u) => [takNodeUpdateKey(u), u.chatReachable])).toEqual([ + ['meshtastic:1', false], + ['meshcore:2', true], + ]); + // Gaining or losing it is a change worth sending. + const mc = updates[1]; + expect(takNodeUpdateSignature(mc)).not.toBe( + takNodeUpdateSignature({ ...mc, chatReachable: false }), + ); + }); + it('leaves out our Reticulum position when it is the (0, 0) placeholder', () => { const updates = collectTakNodeUpdates( sources({ reticulumSelf: { nodeId: 77, name: 'Base', latitude: 0, longitude: 0 } }), diff --git a/src/renderer/lib/takNodeFeed.ts b/src/renderer/lib/takNodeFeed.ts index a930b298a..89cb643e5 100644 --- a/src/renderer/lib/takNodeFeed.ts +++ b/src/renderer/lib/takNodeFeed.ts @@ -36,6 +36,8 @@ export interface TakFeedSources { reticulumSelf: TakReticulumSelf | null; /** Latest `!MT1` fix per tracker heard on an enabled MeshCore channel. */ trackerFixes?: readonly TakTrackerEntry[]; + /** Whether this app can send a mesh node a direct message; unset, none can be. */ + canDirectMessage?: (node: MeshNode, protocol: MeshProtocol) => boolean; } /** A tracker fix while it is still valid; expired fixes drop off. */ @@ -130,7 +132,9 @@ export function collectTakNodeUpdates(sources: TakFeedSources): TAKNodeUpdate[] for (const protocol of REGISTERED_MESH_PROTOCOLS) { for (const node of sources.nodesByProtocol[protocol].values()) { const update = meshNodeToTakUpdate(node, protocol); - if (update) out.push(update); + if (!update) continue; + update.chatReachable = sources.canDirectMessage?.(node, protocol) ?? false; + out.push(update); } } for (const row of sources.rmapRows) { @@ -180,5 +184,6 @@ export function takNodeUpdateSignature(update: TAKNodeUpdate): string { update.course ?? '', update.sequence ?? '', update.stale_sec ?? '', + update.chatReachable ? 'dm' : '', ].join('|'); } diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 7602f1b4b..3ce58cda1 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -6802,7 +6802,9 @@ "channelRelayTrackers": "Tracker fixes", "channelRelayNoChannels": "Connect a MeshCore radio to list its channels.", "channelRelayNoChannelsMeshtastic": "Connect a Meshtastic radio to list its channels.", - "channelRelayUnnamed": "Channel {{n}}" + "channelRelayUnnamed": "Channel {{n}}", + "directMessages": "Direct messages", + "directMessagesHint": "Let ATAK devices message this radio's companions. Each companion this radio can message gets a chat option in ATAK, and a message to it goes out from this radio, prefixed with the sender's callsign. Its replies go to the ATAK device that last wrote to it, or to every device when none has. A companion with no position appears at 0, 0 for a day after a message." }, "telemetryPanel": { "title": "Telemetry", diff --git a/src/renderer/stores/takChannelStore.ts b/src/renderer/stores/takChannelStore.ts index 98546f94b..54d9ce499 100644 --- a/src/renderer/stores/takChannelStore.ts +++ b/src/renderer/stores/takChannelStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import type { ChannelIdentityProtocol } from '@/shared/channelIdentityKey'; +import type { TakDmProtocol } from '@/shared/tak-types'; import type { IdentityId } from '../lib/types'; @@ -19,10 +20,13 @@ export interface TakChannelInfo { interface TakChannelStoreState { /** Each radio's configured channels, in slot order. */ channelsByIdentity: Record; + /** The radio TAK uses for each protocol: the one whose channels it serves and DMs it sends. */ + radioByProtocol: Partial>; } export const useTakChannelStore = create()(() => ({ channelsByIdentity: {}, + radioByProtocol: {}, })); const NO_CHANNELS: readonly TakChannelInfo[] = []; @@ -52,3 +56,18 @@ export function setTakChannels(identityId: IdentityId, channels: readonly TakCha export function getTakChannels(identityId: IdentityId): readonly TakChannelInfo[] { return useTakChannelStore.getState().channelsByIdentity[identityId] ?? NO_CHANNELS; } + +/** Record the radio TAK uses for `protocol`, or that there is none. */ +export function setTakRadio(protocol: TakDmProtocol, identityId: IdentityId | null): void { + useTakChannelStore.setState((s) => { + if ((s.radioByProtocol[protocol] ?? null) === identityId) return s; + const others = Object.fromEntries( + Object.entries(s.radioByProtocol).filter(([p]) => p !== protocol), + ) as Partial>; + return { radioByProtocol: identityId ? { ...others, [protocol]: identityId } : others }; + }); +} + +export function getTakRadio(protocol: TakDmProtocol): IdentityId | undefined { + return useTakChannelStore.getState().radioByProtocol[protocol]; +} diff --git a/src/renderer/stores/takRelayPrefsStore.test.ts b/src/renderer/stores/takRelayPrefsStore.test.ts index 6ed3363a4..17963d2c5 100644 --- a/src/renderer/stores/takRelayPrefsStore.test.ts +++ b/src/renderer/stores/takRelayPrefsStore.test.ts @@ -20,6 +20,7 @@ const EMPTY = { sendChannels: [], remoteChannels: [], atakNames: {}, + directMessages: false, }; const PUBLIC = '0123456789abcdef'; @@ -77,6 +78,18 @@ describe('takRelayPrefsStore', () => { }); }); + it('turns direct messages on and off per radio', () => { + const { setDirectMessages } = useTakRelayPrefsStore.getState(); + setDirectMessages('a', true); + expect(getTakRelayPrefs('a').directMessages).toBe(true); + expect(getTakRelayPrefs('b').directMessages).toBe(false); + const before = useTakRelayPrefsStore.getState().byIdentity; + setDirectMessages('a', true); + expect(useTakRelayPrefsStore.getState().byIdentity).toBe(before); + setDirectMessages('a', false); + expect(getTakRelayPrefs('a').directMessages).toBe(false); + }); + it('ignores invalid tracker slots and channel keys', () => { const state = useTakRelayPrefsStore.getState(); state.setTrackerChannel('a', -1, true); @@ -109,6 +122,7 @@ describe('takRelayPrefsStore', () => { sendChannels: [OPS], remoteChannels: [PUBLIC], atakNames: { [OPS]: 'Ops Net' }, + directMessages: true, }; await rehydrate({ byIdentity: { a: saved } }, 3); expect(getTakRelayPrefs('a')).toEqual(saved); @@ -125,6 +139,7 @@ describe('takRelayPrefsStore', () => { sendChannels: 'all', remoteChannels: [PUBLIC], atakNames: { [OPS]: ' Ops ', [PUBLIC]: '', [SAR]: 'two\nlines', x: 'X' }, + directMessages: 'yes', }, b: 'nope', }, @@ -139,6 +154,7 @@ describe('takRelayPrefsStore', () => { sendChannels: [], remoteChannels: [PUBLIC], atakNames: { [OPS]: 'Ops' }, + directMessages: false, }, }); }); diff --git a/src/renderer/stores/takRelayPrefsStore.ts b/src/renderer/stores/takRelayPrefsStore.ts index d9dd12120..f9db22456 100644 --- a/src/renderer/stores/takRelayPrefsStore.ts +++ b/src/renderer/stores/takRelayPrefsStore.ts @@ -42,6 +42,8 @@ export interface TakRelayIdentityPrefs { * gets its automatic name. */ atakNames: Record; + /** TAK devices may message this radio's companions, and hear their direct messages. */ + directMessages: boolean; /** Settings from before the upgrade, by slot, until the radio's channels are known. */ pendingSlots?: TakPendingSlotPrefs; } @@ -67,6 +69,7 @@ const EMPTY_PREFS: TakRelayIdentityPrefs = { sendChannels: [], remoteChannels: [], atakNames: {}, + directMessages: false, }; const CONTROL_CHARS_RE = /\p{Cc}/u; @@ -164,7 +167,7 @@ function fromVersion1(v: Record): { */ function channelPrefs( v: Record, -): Omit { +): Omit { const stored = v.pendingSlots && typeof v.pendingSlots === 'object' ? (v.pendingSlots as Record) @@ -210,7 +213,12 @@ function sanitizeStore(raw: unknown): { const channelMode = CHANNEL_MODES.includes(v.channelMode as TakChannelMode) ? (v.channelMode as TakChannelMode) : 'off'; - byIdentity[identityId] = { trackerChannels, channelMode, ...channelPrefs(v) }; + byIdentity[identityId] = { + trackerChannels, + channelMode, + ...channelPrefs(v), + directMessages: v.directMessages === true, + }; } return { byIdentity, contactRooms: [...contactRooms] }; } @@ -248,6 +256,8 @@ interface TakRelayPrefsState { * refuse (too long, or with a line break) is ignored. */ setAtakName: (identityId: IdentityId, key: string, name: string) => void; + /** Let TAK devices message this radio's companions. */ + setDirectMessages: (identityId: IdentityId, enabled: boolean) => void; } export const useTakRelayPrefsStore = create()( @@ -316,6 +326,11 @@ export const useTakRelayPrefsStore = create()( return { ...prev, atakNames }; }); }, + setDirectMessages: (identityId, enabled) => { + update(identityId, (prev) => + prev.directMessages === enabled ? null : { ...prev, directMessages: enabled }, + ); + }, }; }, { diff --git a/src/renderer/vitest.electronApiMock.ts b/src/renderer/vitest.electronApiMock.ts index dccb25bf3..4e0014c06 100644 --- a/src/renderer/vitest.electronApiMock.ts +++ b/src/renderer/vitest.electronApiMock.ts @@ -508,6 +508,8 @@ export function createElectronAPIMock(): ElectronAPI { pushChatMessage: vi.fn().mockResolvedValue(undefined), setChatChannels: vi.fn().mockResolvedValue(undefined), retireChatRooms: vi.fn().mockResolvedValue(undefined), + pushDirectMessage: vi.fn().mockResolvedValue(undefined), + setDirectMessaging: vi.fn().mockResolvedValue(undefined), onInboundChat: vi.fn().mockReturnValue(() => {}), }, bleCoexistence: { diff --git a/src/shared/electron-api.types.ts b/src/shared/electron-api.types.ts index e8a2c9d1e..8db15c5a5 100644 --- a/src/shared/electron-api.types.ts +++ b/src/shared/electron-api.types.ts @@ -77,6 +77,8 @@ import type { TAKClientInfo, TAKContact, TAKContactsUpdate, + TAKDirectMessage, + TakDmProtocol, TAKEnrollmentRequest, TAKInboundChat, TAKNodeUpdate, @@ -1459,6 +1461,13 @@ export interface ElectronAPI { * ignored while no sink has started. */ retireChatRooms: (rooms: string[]) => Promise; + /** Deliver one direct message heard from a companion to TAK clients; dropped with no sink. */ + pushDirectMessage: (message: TAKDirectMessage) => Promise; + /** + * Set the radios whose companions TAK clients may message; ignored while no sink has + * started. + */ + setDirectMessaging: (protocols: TakDmProtocol[]) => Promise; /** How relayed nodes are styled: user unit filters over each node's advertised role. */ getStyleSettings: () => Promise; /** Validate, save, and apply style settings; resolves with what was saved. */ diff --git a/src/shared/tak-types.ts b/src/shared/tak-types.ts index 1c3062ef3..8c8690af3 100644 --- a/src/shared/tak-types.ts +++ b/src/shared/tak-types.ts @@ -198,6 +198,12 @@ export interface TakRelayExtras { stale_sec?: number; /** Fixed network infrastructure with no advertised role, such as an RMAP interface. */ infrastructure?: boolean; + /** + * This app can send the node a direct message: a MeshCore contact whose key it holds, or any + * Meshtastic node. Main gives the marker a chat endpoint only when the node is a companion and + * direct messages are on for its radio. + */ + chatReachable?: boolean; } /** One node position pushed from the renderer to the TAK sinks. */ @@ -262,10 +268,22 @@ export interface TAKChannelChatMessage { longitude?: number; } -/** A message a local TAK client sent to a served channel's contact, for the mesh. */ +/** Radios whose companions TAK clients may message directly. */ +export const TAK_DM_PROTOCOLS = ['meshcore', 'meshtastic'] as const; +export type TakDmProtocol = (typeof TAK_DM_PROTOCOLS)[number]; + +export function isTakDmProtocol(value: unknown): value is TakDmProtocol { + return (TAK_DM_PROTOCOLS as readonly unknown[]).includes(value); +} + +/** Where a TAK client's message goes: a served channel, or one companion as a direct message. */ +export type TakChatTarget = + | { kind: 'channel'; channelKey: string } + | { kind: 'direct'; protocol: TakDmProtocol; nodeId: number }; + +/** A message a local TAK client sent to a channel's or a companion's contact, for the mesh. */ export interface TAKInboundChat { - /** Identity key of the channel whose contact the client messaged. */ - channelKey: string; + target: TakChatTarget; senderCallsign: string; text: string; /** Server-side id of the client connection that sent it. */ @@ -274,6 +292,18 @@ export interface TAKInboundChat { timeMs: number; } +/** A direct message heard from a companion, for TAK clients. */ +export interface TAKDirectMessage { + protocol: TakDmProtocol; + /** The companion's node id; its ATAK contact uid comes from it. */ + nodeId: number; + /** Its name as heard, used when TAK has no marker for it. */ + senderCallsign: string; + text: string; + /** When the message was sent, epoch milliseconds. */ + timeMs: number; +} + /** * Uid prefix of the contact that stands for a served mesh channel. A client reaches the channel * by messaging that contact. diff --git a/vitest.config.mts b/vitest.config.mts index e181fdc85..2999613f6 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -99,6 +99,7 @@ const RENDERER_LOGIC_EXCLUDE = [ 'src/renderer/lib/tak/takChannelRelay.test.ts', 'src/renderer/lib/tak/takServedChannels.test.ts', 'src/renderer/lib/tak/takMeshSend.test.ts', + 'src/renderer/lib/tak/takDirectMessages.test.ts', 'src/renderer/lib/letsMeshJwt.test.ts', 'src/renderer/lib/messageRetention.test.ts', 'src/renderer/lib/rrcMessagePersist.test.ts', From a55f372588c906b340de16a2e0eb35bc008221e9 Mon Sep 17 00:00:00 2001 From: "Robert M. Talbot III" Date: Fri, 16 Oct 2026 15:09:48 -0600 Subject: [PATCH 2/4] i18n: translate the TAK direct message strings --- src/renderer/locales/cs/translation.json | 4 +++- src/renderer/locales/de/translation.json | 4 +++- src/renderer/locales/es/translation.json | 4 +++- src/renderer/locales/fr/translation.json | 4 +++- src/renderer/locales/id/translation.json | 4 +++- src/renderer/locales/it/translation.json | 4 +++- src/renderer/locales/ja/translation.json | 4 +++- src/renderer/locales/ko/translation.json | 4 +++- src/renderer/locales/nl/translation.json | 4 +++- src/renderer/locales/pl/translation.json | 4 +++- src/renderer/locales/pt-BR/translation.json | 4 +++- src/renderer/locales/ru/translation.json | 4 +++- src/renderer/locales/tr/translation.json | 4 +++- src/renderer/locales/uk/translation.json | 4 +++- src/renderer/locales/zh/translation.json | 4 +++- 15 files changed, 45 insertions(+), 15 deletions(-) diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index d746d5c13..0061d10b0 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -5848,7 +5848,9 @@ "channelSendFor": "Nechte zařízení Atak odeslat na {{channel}}", "channelRemote": "Vzdálená", "channelRemoteFor": "Stream {{channel}} na vzdálený TAK server", - "channelTrackersFor": "Opravy trackeru od {{channel}}" + "channelTrackersFor": "Opravy trackeru od {{channel}}", + "directMessages": "Přímé zprávy:", + "directMessagesHint": "Nechte zařízení Atak odeslat zprávu společníkům tohoto rádia. Každý společník, kterému může toto rádio poslat zprávu, dostane možnost chatu v atak a zpráva na něj vyjde z tohoto rádia s předponou volací značky odesílatele. Jeho odpovědi jdou do zařízení atak, které na něj naposledy napsalo, nebo do každého zařízení, pokud žádné nemá. Společník bez pozice se zobrazí na 0, 0 po dobu jednoho dne po zprávě." }, "telemetryPanel": { "title": "Telemetrie", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index d6919c004..31ef7efc7 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -5803,7 +5803,9 @@ "channelSendFor": "ATAK-Geräte an {{channel}} senden lassen", "channelRemote": "Remote Server", "channelRemoteFor": "{{channel}} zum entfernten TAK -Server streamen", - "channelTrackersFor": "Tracker-Fixes von {{channel}}" + "channelTrackersFor": "Tracker-Fixes von {{channel}}", + "directMessages": "Direktnachrichten", + "directMessagesHint": "Lassen Sie ATAK-Geräte die Begleiter dieses Radios benachrichtigen. Jeder Begleiter, der diese Funknachricht empfangen kann, erhält eine Chat-Option in ATAK, und eine Nachricht an ihn geht von diesem Funkgerät aus, der das Rufzeichen des Absenders vorangestellt ist. Die Antworten gehen an das ATAK-Gerät, das zuletzt an es geschrieben hat, oder an jedes Gerät, wenn dies nicht der Fall ist. Ein Begleiter ohne Position erscheint für einen Tag nach einer Nachricht bei 0, 0." }, "telemetryPanel": { "title": "Telemetrie", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 65ec6c790..f5067f9e1 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -5803,7 +5803,9 @@ "channelSendFor": "Permitir que los dispositivos ATAK se envíen a {{channel}}", "channelRemote": "Servidor remoto", "channelRemoteFor": "Transmite {{channel}} al servidor TAK remoto", - "channelTrackersFor": "Correcciones del rastreador de {{channel}}" + "channelTrackersFor": "Correcciones del rastreador de {{channel}}", + "directMessages": "Mensajes directos", + "directMessagesHint": "Deje que los dispositivos ATAK envíen mensajes a los compañeros de esta radio. Cada acompañante de este mensaje de radio Can recibe una opción de chat en ATAK, y sale un mensaje de esta radio, con el prefijo del indicativo del remitente. Sus respuestas van al dispositivo ATAK que le escribió por última vez, o a todos los dispositivos cuando ninguno lo ha hecho. Un compañero sin posición aparece en 0, 0 durante un día después de un mensaje." }, "telemetryPanel": { "title": "Telemetría", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index cccafae45..f30ec0b97 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -5803,7 +5803,9 @@ "channelSendFor": "Laissez les appareils ATAK envoyer à {{channel}}", "channelRemote": "Serveur distant", "channelRemoteFor": "Flux {{channel}} vers le serveur TAK distant", - "channelTrackersFor": "Correctifs de suivi de {{channel}}" + "channelTrackersFor": "Correctifs de suivi de {{channel}}", + "directMessages": "Messages directs", + "directMessagesHint": "Laissez les appareils ATAK envoyer un message aux compagnons de cette radio. Chaque compagnon de ce message radio CAN obtient une option de chat dans ATAK, et un message lui est envoyé par cette radio, précédé de l'indicatif d'appel de l'expéditeur. Ses réponses vont à l'appareil ATAK qui lui a écrit en dernier, ou à chaque appareil quand aucun ne l'a fait. Un compagnon sans position apparaît à 0, 0 pendant un jour après un message." }, "telemetryPanel": { "title": "Télémétrie", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index fa2ae2c32..778fb7619 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -5803,7 +5803,9 @@ "channelSendFor": "Biarkan perangkat atak mengirim ke {{channel}}", "channelRemote": "Server jarak jauh", "channelRemoteFor": "Alirkan {{channel}} ke server TAK jarak jauh", - "channelTrackersFor": "Perbaikan pelacak dari {{channel}}" + "channelTrackersFor": "Perbaikan pelacak dari {{channel}}", + "directMessages": "Direct message", + "directMessagesHint": "Biarkan perangkat atak mengirim pesan ke pendamping radio ini. Setiap pendamping radio ini dapat mengirim pesan mendapatkan opsi obrolan di atak, dan pesan ke sana keluar dari radio ini, diawali dengan tanda panggil pengirim. Balasannya masuk ke perangkat atak yang terakhir menulisnya, atau ke setiap perangkat ketika tidak ada. Pendamping tanpa posisi muncul di 0, 0 untuk sehari setelah pesan." }, "telemetryPanel": { "title": "Telemetri", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 1ead08c81..ec0e5932f 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -5803,7 +5803,9 @@ "channelSendFor": "Consenti ai dispositivi ATAK di inviare a {{channel}}", "channelRemote": "Server remoto", "channelRemoteFor": "Trasmetti {{channel}} in streaming sul server TAK remoto", - "channelTrackersFor": "Correzioni del tracker da {{channel}}" + "channelTrackersFor": "Correzioni del tracker da {{channel}}", + "directMessages": "Messaggi diretti", + "directMessagesHint": "Lascia che i dispositivi ATAK inviino messaggi ai compagni di questa radio. Ogni compagno di questo messaggio radio can ottiene un'opzione di chat in ATAK e da questa radio esce un messaggio ad esso, preceduto dal nominativo del mittente. Le sue risposte vanno al dispositivo ATAK che ha scritto per ultimo, o a ogni dispositivo quando nessuno ne ha scritto. Un compagno senza posizione appare a 0, 0 per un giorno dopo un messaggio." }, "telemetryPanel": { "title": "Telemetria", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 99c564d30..89612cb93 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -5803,7 +5803,9 @@ "channelSendFor": "ATAKデバイスを{{channel}}に送信する", "channelRemote": "リモートサーバ", "channelRemoteFor": "リモートのTAKサーバーに{{channel}}をストリーミング", - "channelTrackersFor": "{{channel}}からのトラッカーの修正" + "channelTrackersFor": "{{channel}}からのトラッカーの修正", + "directMessages": "ダイレクトメッセージ", + "directMessagesHint": "ATAKデバイスがこのラジオの仲間にメッセージを送るようにしましょう。このラジオCANメッセージの各コンパニオンは、ATAKでチャットオプションを取得し、送信者のコールサインをプレフィックスしたメッセージがこのラジオから送信されます。応答は、最後に書き込まれたATAKデバイス、または書き込まれていないすべてのデバイスに送信されます。メッセージの1日後、位置のないコンパニオンが0、0に表示されます。" }, "telemetryPanel": { "title": "テレメトリー", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 70ff27d21..f81ed1606 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -5803,7 +5803,9 @@ "channelSendFor": "ATAK 장치가 {{channel}} (으) 로 전송되도록 합니다.", "channelRemote": "원격 서버", "channelRemoteFor": "원격 TAK 서버로 {{channel}} 스트리밍", - "channelTrackersFor": "{{channel}} 의 트래커 보완 사항" + "channelTrackersFor": "{{channel}} 의 트래커 보완 사항", + "directMessages": "다이렉트 메시지", + "directMessagesHint": "ATAK 장치가 이 라디오의 동반자에게 메시지를 보냅니다. 이 라디오 캔 메시지의 각 동반자는 ATAK의 채팅 옵션을 받게 되며, 이 라디오에서 발신자의 콜사인이 붙은 메시지가 나옵니다. 응답은 마지막으로 쓴 ATAK 장치 또는 아무도 없는 모든 장치로 전송됩니다. 포지션이 없는 동반자는 메시지 후 하루 동안 0, 0으로 나타납니다." }, "telemetryPanel": { "title": "원격 측정", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 470118c0a..06039715c 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -5803,7 +5803,9 @@ "channelSendFor": "Laat ATAK-apparaten naar {{channel}} sturen", "channelRemote": "Server", "channelRemoteFor": "Stream {{channel}} naar de externe TAK -server", - "channelTrackersFor": "Trackerfixes van {{channel}}" + "channelTrackersFor": "Trackerfixes van {{channel}}", + "directMessages": "Directe berichten", + "directMessagesHint": "Laat ATAK-apparaten berichten sturen naar de metgezellen van deze radio. Elke metgezel van dit radiobericht krijgt een chatoptie in ATAK en er gaat een bericht naar toe van deze radio, voorafgegaan door het roepnaam van de afzender. De antwoorden gaan naar het ATAK-apparaat dat er het laatst naar heeft geschreven, of naar elk apparaat dat er geen heeft. Een metgezel zonder positie verschijnt een dag na een bericht op 0, 0." }, "telemetryPanel": { "title": "Telemetrie", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index ec8e90ae2..7bb295547 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -5887,7 +5887,9 @@ "channelSendFor": "Niech urządzenia atak wyślą do {{channel}}", "channelRemote": "Serwer zdalny", "channelRemoteFor": "Przesyłaj strumieniowo {{channel}} na zdalny serwer TAK", - "channelTrackersFor": "Poprawki trackera od {{channel}}" + "channelTrackersFor": "Poprawki trackera od {{channel}}", + "directMessages": "Bezpośrednie wiadomości", + "directMessagesHint": "Niech urządzenia atak wyślą wiadomość do towarzyszy tego radia. Każdy towarzysz tej wiadomości can radiowej otrzymuje opcję czatu w atak, a wiadomość do niego wychodzi z tego radia, poprzedzoną znakiem wywoławczym nadawcy. Jego odpowiedzi trafiają do urządzenia atak, które ostatnio do niego pisało, lub do każdego urządzenia, gdy żadne tego nie zrobiło. Towarzysz bez pozycji pojawia się w 0, 0 przez dzień po wiadomości." }, "telemetryPanel": { "title": "Telemetria", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 083b6a10e..037bafedd 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -5803,7 +5803,9 @@ "channelSendFor": "Permitir que os dispositivos ATAK sejam enviados para {{channel}}", "channelRemote": "Servidor remoto", "channelRemoteFor": "Transmita {{channel}} para o servidor TAK remoto", - "channelTrackersFor": "Correções do rastreador de {{channel}}" + "channelTrackersFor": "Correções do rastreador de {{channel}}", + "directMessages": "Mensagens diretas", + "directMessagesHint": "Deixe os dispositivos ATAK enviarem mensagens aos companheiros deste rádio. Cada acompanhante desta mensagem de rádio can recebe uma opção de bate-papo no ATAK, e uma mensagem para ela sai deste rádio, prefixada com o indicativo do remetente. Suas respostas vão para o dispositivo ATAK que escreveu pela última vez nele, ou para todos os dispositivos quando nenhum o fez. Um acompanhante sem posição aparece em 0, 0 por um dia após uma mensagem." }, "telemetryPanel": { "title": "Telemetria", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index b1c0a6ad2..1a875575d 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -5887,7 +5887,9 @@ "channelSendFor": "Пусть устройства ATAK отправляют на {{channel}}", "channelRemote": "Удалённый сервер", "channelRemoteFor": "Поток {{channel}} на удаленный сервер TAK", - "channelTrackersFor": "Исправления в трекере от {{channel}}" + "channelTrackersFor": "Исправления в трекере от {{channel}}", + "directMessages": "Прямые сообщения", + "directMessagesHint": "Позвольте устройствам ATAK отправлять сообщения спутникам этого радио. Каждому собеседнику это радиосообщение can получает опцию чата в ATAK, и сообщение ему отправляется с этого радио, с префиксом позывного отправителя. Его ответы отправляются на устройство ATAK, которое в последний раз писало на него, или на каждое устройство, когда его нет. Компаньон без позиции появляется на 0, 0 в течение дня после сообщения." }, "telemetryPanel": { "title": "Телеметрия", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 8234505c0..7d17b86fd 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -5803,7 +5803,9 @@ "channelSendFor": "Atak cihazlarının {{channel}} adresine göndermesine izin verin", "channelRemote": "Uzak sunucu", "channelRemoteFor": "{{channel}} 'ı uzaktaki TAK sunucusuna aktarın", - "channelTrackersFor": "{{channel}} 'dan itibaren izleyici düzeltmeleri" + "channelTrackersFor": "{{channel}} 'dan itibaren izleyici düzeltmeleri", + "directMessages": "Direkt Mesajlar", + "directMessagesHint": "Atak cihazları bu radyonun arkadaşlarına mesaj göndersin. Bu telsiz mesajının her arkadaşı atak'ta bir sohbet seçeneği alır ve bu telsizden gönderenin çağrı işareti ile başlayan bir mesaj çıkar. Yanıtları, en son kendisine yazan atak cihazına veya hiçbiri yazmadığında her cihaza gider. Pozisyonu olmayan bir refakatçi, bir mesajdan sonra bir gün boyunca 0, 0 'da görünür." }, "telemetryPanel": { "title": "Telemetri", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index a6f731925..176405ae1 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -5887,7 +5887,9 @@ "channelSendFor": "Дозвольте пристроям ATAK надсилати на {{channel}}", "channelRemote": "Віддалений сервер", "channelRemoteFor": "Потік {{channel}} на віддалений сервер TAK", - "channelTrackersFor": "Виправлення відстеження від {{channel}}" + "channelTrackersFor": "Виправлення відстеження від {{channel}}", + "directMessages": "Приватні повідомлення", + "directMessagesHint": "Дозвольте пристроям ATAK надсилати повідомлення компаньйонам цього радіо. Кожен супутник цього радіоповідомлення can отримує опцію чату в ATAK, і повідомлення до нього виходить з цього радіо з префіксом позивного відправника. Його відповіді надходять на пристрій ATAK, який востаннє писав на нього, або на кожен пристрій, коли його немає. Компаньйон без позиції з'являється на 0, 0 протягом дня після повідомлення." }, "telemetryPanel": { "title": "Телеметрія", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index bb493977e..30ed1c4dc 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -5803,7 +5803,9 @@ "channelSendFor": "让ATAK设备发送到{{channel}}", "channelRemote": "远程服务器", "channelRemoteFor": "将{{channel}}流式传输到远程TAK服务器", - "channelTrackersFor": "来自{{channel}}的跟踪器修复" + "channelTrackersFor": "来自{{channel}}的跟踪器修复", + "directMessages": "实时消息", + "directMessagesHint": "让ATAK设备向此收音机的同伴发送消息。此收音机CAN消息的每个伴侣都会在ATAK中获得聊天选项,并从此收音机发出消息,前缀为发件人的呼号。它的回复会发送到上次写入它的ATAK设备,或者在没有设备时发送到每个设备。没有位置的伴侣在消息后一天出现在0 , 0。" }, "telemetryPanel": { "title": "遥测", From f7a09486aafb66921a09163cdc9f2fe828b2479b Mon Sep 17 00:00:00 2001 From: "Robert M. Talbot" Date: Fri, 16 Oct 2026 12:37:55 -0600 Subject: [PATCH 3/4] feat(tak): delivery receipts for direct messages from ATAK A direct message sent from ATAK now tells the device that wrote it how it went, once mesh-client's automatic resends settle: - Acknowledged by the companion: a b-t-f-d delivery receipt naming the device's message id, so ATAK marks it delivered. - Still unacknowledged after the last resend (4 tries): a "Not confirmed" note in the conversation, quoting the message. For MeshCore it adds that a flood advert from each radio usually fixes a DM that keeps failing. - Never sent (no radio could take it): a "Not sent to the mesh" note. A note for a device that is away waits with the held messages; a receipt is dropped. Main waits up to 30 minutes for an outcome. The resend controller reports how each send's lineage ends (acked or gave up), keyed by the first send's store id through re-keys and resends, via subscribeAutoResendOutcomes. takDmStatus.ts watches DMs sent from TAK and reports their outcome over the new tak:dmStatus IPC, keyed by a dmRef main attaches to each inbound DM. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EHFPfW7vZXpRdR3tBBTX3r --- README.md | 2 +- src/main/index.ipc-security.test.ts | 1 + src/main/ipc/tak-handlers.test.ts | 18 +++ src/main/ipc/tak-handlers.ts | 14 ++ src/main/tak-server-manager.test.ts | 96 +++++++++++++ src/main/tak-server-manager.ts | 128 ++++++++++++++++++ src/main/tak/geochat-cot.test.ts | 44 ++++++ src/main/tak/geochat-cot.ts | 51 +++++++ src/preload/index.ts | 3 + src/renderer/hooks/useTakInboundChat.ts | 4 + .../autoResend/autoResendController.test.ts | 58 ++++++++ .../lib/autoResend/autoResendController.ts | 66 ++++++++- src/renderer/lib/devElectronApiStub.ts | 1 + src/renderer/lib/tak/takDmStatus.test.ts | 111 +++++++++++++++ src/renderer/lib/tak/takDmStatus.ts | 62 +++++++++ src/renderer/lib/tak/takInboundChat.test.ts | 41 ++++++ src/renderer/lib/tak/takInboundChat.ts | 14 +- src/renderer/lib/tak/takMeshSend.test.ts | 31 +++++ src/renderer/lib/tak/takMeshSend.ts | 4 + src/renderer/locales/en/translation.json | 2 +- src/renderer/vitest.electronApiMock.ts | 1 + src/shared/electron-api.types.ts | 6 + src/shared/tak-types.ts | 19 +++ vitest.config.mts | 1 + 24 files changed, 772 insertions(+), 6 deletions(-) create mode 100644 src/renderer/lib/tak/takDmStatus.test.ts create mode 100644 src/renderer/lib/tak/takDmStatus.ts diff --git a/README.md b/README.md index e347d13a1..a60471690 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ Mesh-Client supports **three mesh stacks** in one desktop app. Use the **protoco - **Certificate management**: self-signed CA + server + client certificates via node-forge (server cert includes DNS + LAN IP in Subject Alternative Name so EUDs can verify TLS); regenerate anytime from the TAK tab - **Data package generator**: export ATAK-compatible (truststore.p12, client.p12, connection.pref) for direct import on TAK devices — connect host is your LAN IP and must match the server cert SAN; re-generate the package after certificate regeneration or a LAN IP change - **Channels as ATAK contacts**: under **MeshCore messaging in ATAK** and **Meshtastic messaging in ATAK**, choose Off, All channels, or Selected channels. Each served channel becomes a contact on every connected ATAK device, named with its protocol (`#MC Public`, `#MC ops` for a hashtag channel, `#MT 🔒 Ops` for a private one) unless you type your own in its **ATAK name** field, and what the channel hears arrives from that contact. Tick **ATAK can send** for a channel and a message to its contact is transmitted on that channel from this radio, prefixed with the sender's callsign (`[VIPER] on scene`), and shows in Chat like any other; left off, the channel is receive only. Tick **Remote server** to also stream the channel, read only, to the remote TAK server. Sending is off by default, cut to fit one mesh message, and limited to 10 messages a minute per device. Up to 50 messages heard while no ATAK device is connected are held for the first one to connect. Settings from earlier versions carry over: a channel that had a GeoChat room is served, keeps its send setting and its room name (as its ATAK name), and streams to the remote server. Channel settings are kept per channel, not per slot, so a channel moving into another's slot never inherits its settings, and ATAK devices are told to drop the old room contacts. -- **Direct messages with companions**: tick **Direct messages** under a radio's messaging section and every companion that radio can message (a MeshCore companion in its contacts, any Meshtastic node) can be messaged from its marker in ATAK. The message goes out from this radio as a direct message, prefixed with the sender's callsign, and shows in Chat like any other. A companion's replies go to the ATAK device that last wrote to it, or to every connected device when none has, and are held for that device while it is away. A companion with no marker on the map appears at 0, 0 for a day after a message. Direct messages are never sent to the remote TAK server, and a message ATAK cannot deliver (direct messages off, or not a companion this radio can reach) gets a note in its conversation saying why. +- **Direct messages with companions**: tick **Direct messages** under a radio's messaging section and every companion that radio can message (a MeshCore companion in its contacts, any Meshtastic node) can be messaged from its marker in ATAK. The message goes out from this radio as a direct message, prefixed with the sender's callsign, and shows in Chat like any other. ATAK marks it delivered when the companion acknowledges it; if it still isn't acknowledged after mesh-client's automatic resends, a "Not confirmed" note says so (for MeshCore, with a reminder that a flood advert from each radio usually fixes a DM that keeps failing). A companion's replies go to the ATAK device that last wrote to it, or to every connected device when none has, and are held for that device while it is away. A companion with no marker on the map appears at 0, 0 for a day after a message. Direct messages are never sent to the remote TAK server, and a message ATAK cannot deliver (direct messages off, or not a companion this radio can reach) gets a note in its conversation saying why. - **Unit filters**: under **Relayed unit styles**, restyle relayed nodes by name, node type (companions and clients, repeaters and routers, room servers, sensors, trackers), protocol, or a combination, choosing the TAK unit type, team color, and team role. A filter with no name pattern applies to every node of its type or protocol; the first enabled filter that matches wins - **Repeater and room server icons**: under **Relayed unit styles**, give repeaters (MeshCore repeaters, Meshtastic routers, Reticulum interfaces) and room servers each an icon from an ATAK icon set, as a path (`//`) or from the GeoOps WildFire presets that MeshCore TAK Bridge offers (Repeater, Repeater Mobile Relay, Ranger Station and others). The icon follows the node type, so a filter can change a room server's team and role and it keeps its icon. Leave a field empty for the standard symbol. Each TAK device needs the same icon set installed; a device without it draws the standard symbol - Auto-start option (off by default); status indicator in header when running diff --git a/src/main/index.ipc-security.test.ts b/src/main/index.ipc-security.test.ts index a3139b938..032aa98ed 100644 --- a/src/main/index.ipc-security.test.ts +++ b/src/main/index.ipc-security.test.ts @@ -456,6 +456,7 @@ describe('GPS/TAK IPC sender validation (source contract)', () => { 'tak:retireChatRooms', 'tak:pushDirectMessage', 'tak:setDirectMessaging', + 'tak:dmStatus', ] as const; it.each(takChannels)('tak handler %s calls assertIpcSender', (channel) => { diff --git a/src/main/ipc/tak-handlers.test.ts b/src/main/ipc/tak-handlers.test.ts index 1b5bb9374..2ff2f7522 100644 --- a/src/main/ipc/tak-handlers.test.ts +++ b/src/main/ipc/tak-handlers.test.ts @@ -602,6 +602,24 @@ describe('direct message handlers', () => { expect(sendDirectMessage).not.toHaveBeenCalled(); }); + const STATUS = { dmRef: '0c2f8e1a-5b3d-4c7e-9a10-2b4d6f8a0c1e', outcome: 'delivered' }; + + it('validates a direct message outcome and hands it on while a sink is active', async () => { + const reportDmOutcome = vi.fn(); + const handler = await register({ hasActiveSink: () => true, reportDmOutcome }, 'tak:dmStatus'); + handler({}, STATUS); + expect(reportDmOutcome).toHaveBeenCalledWith(STATUS); + expect(assertIpcSender).toHaveBeenCalledWith({}, 'tak:dmStatus'); + expect(() => handler({}, { ...STATUS, outcome: 'read' })).toThrow(/tak:dmStatus/); + expect(reportDmOutcome).toHaveBeenCalledTimes(1); + const idle = vi.fn(); + (await register({ hasActiveSink: () => false, reportDmOutcome: idle }, 'tak:dmStatus'))( + {}, + STATUS, + ); + expect(idle).not.toHaveBeenCalled(); + }); + it('hands the radios with direct messages on to the manager, once it exists', async () => { const setDirectMessaging = vi.fn(); const handler = await register({ setDirectMessaging }, 'tak:setDirectMessaging'); diff --git a/src/main/ipc/tak-handlers.ts b/src/main/ipc/tak-handlers.ts index fa09639cd..017982e1c 100644 --- a/src/main/ipc/tak-handlers.ts +++ b/src/main/ipc/tak-handlers.ts @@ -20,6 +20,7 @@ import { parseTakChatChannels, parseTakDirectMessage, parseTakDmProtocols, + parseTakDmStatus, parseTakRetiredRooms, } from '../tak/geochat-cot'; import { parseTakNodeUpdate } from '../tak/node-update'; @@ -308,6 +309,19 @@ export function registerTakIpcHandlers(deps: TakIpcDeps): void { m.setDirectMessaging(parseTakDmProtocols(protocols)); }); + const dmStatusUpdates = createIpcRateLimiter({ + max: 120, + windowMs: MS_PER_MINUTE, + label: 'tak:dmStatus', + }); + ipcMain.handle('tak:dmStatus', (event, update: unknown) => { + assertIpcSender(event, 'tak:dmStatus'); + const m = getTakServerManager(); + if (!m?.hasActiveSink()) return; + dmStatusUpdates.checkOrThrow(); + m.reportDmOutcome(parseTakDmStatus(update)); + }); + // ─── Relayed node styling ──────────────────────────────────────── ipcMain.handle('tak:getStyleSettings', (event) => { diff --git a/src/main/tak-server-manager.test.ts b/src/main/tak-server-manager.test.ts index f456f1f4b..cf4565fb1 100644 --- a/src/main/tak-server-manager.test.ts +++ b/src/main/tak-server-manager.test.ts @@ -1319,6 +1319,7 @@ describe('TakServerManager direct messages', () => { text: 'meet at the gate', clientId: manager.getConnectedClients()[0]?.id, timeMs: expect.any(Number), + dmRef: expect.any(String), }, ]); expect(sender.write).not.toHaveBeenCalled(); @@ -1492,4 +1493,99 @@ describe('TakServerManager direct messages', () => { watcher.emit('data', Buffer.from(SELF('ANDROID-1', 'VIPER'))); expect(chats(watcher)).toEqual([]); }); + + describe('outcomes', () => { + const receipts = (socket: tls.TLSSocket) => + written(socket).filter((l) => l.includes('type="b-t-f-d"')); + const remarks = (socket: tls.TLSSocket) => + chats(socket).map((l) => />([^<]*)<\/remarks>/.exec(l)?.[1]); + + /** Viper writes to Alice; returns what the renderer was handed. */ + function sendFromViper(text = 'on my way') { + const manager = dmManager(); + manager.onNodeUpdate(ALICE); + const inbound = listen(manager); + const viper = connect(manager, ['ANDROID-1', 'VIPER']); + const eagle = connect(manager, ['ANDROID-2', 'EAGLE']); + viper.emit('data', Buffer.from(DM(text, 'MC-123'))); + vi.mocked(viper.write).mockClear(); + vi.mocked(eagle.write).mockClear(); + const dmRef = inbound[0]?.dmRef; + expect(dmRef).toMatch(/^[0-9a-f-]{36}$/); + return { manager, viper, eagle, dmRef: dmRef! }; + } + + it('sends the device that wrote a delivery receipt naming its message', () => { + const { manager, viper, eagle, dmRef } = sendFromViper(); + manager.reportDmOutcome({ dmRef, outcome: 'delivered' }); + const [receipt] = receipts(viper); + expect(receipt).toContain('uid="msg-9" type="b-t-f-d"'); + expect(receipt).toContain( + '<__chatreceipt parent="RootContactGroup" groupOwner="false" messageId="msg-9"' + + ' chatroom="Alice" id="MC-123" senderCallsign="Alice">', + ); + expect(receipt).toContain(''); + expect(written(eagle)).toEqual([]); + // Reported once; a second report for the same message does nothing. + manager.reportDmOutcome({ dmRef, outcome: 'delivered' }); + expect(receipts(viper)).toHaveLength(1); + }); + + it('writes a "Not confirmed" note with the advert hint after the last resend', () => { + const { manager, viper, dmRef } = sendFromViper('x'.repeat(50)); + manager.reportDmOutcome({ dmRef, outcome: 'unconfirmed' }); + expect(receipts(viper)).toEqual([]); + expect(remarks(viper)).toEqual([ + `mesh-client: Not confirmed: no acknowledgement from Alice after 4 tries. It may not` + + ` have arrived: ${'x'.repeat(40)}… If this keeps happening, send a flood advert` + + ` from each radio so both have current contacts.`, + ]); + expect(chats(viper)[0]).toContain('chatroom="VIPER" id="ANDROID-1" senderCallsign="Alice"'); + }); + + it('leaves the advert hint out for Meshtastic, and says when a message never went out', () => { + const manager = dmManager(); + manager.onNodeUpdate({ ...ALICE, protocol: 'meshtastic', node_id: 456, short_name: 'ALC' }); + const inbound = listen(manager); + const viper = connect(manager, ['ANDROID-1', 'VIPER']); + viper.emit('data', Buffer.from(DM('one', 'MESH-456'))); + viper.emit('data', Buffer.from(DM('two', 'MESH-456'))); + vi.mocked(viper.write).mockClear(); + manager.reportDmOutcome({ dmRef: inbound[0].dmRef!, outcome: 'unconfirmed' }); + manager.reportDmOutcome({ dmRef: inbound[1].dmRef!, outcome: 'not-sent' }); + expect(remarks(viper)).toEqual([ + 'mesh-client: Not confirmed: no acknowledgement from ALC after 4 tries. It may not have arrived: one', + 'mesh-client: Not sent to the mesh: two', + ]); + }); + + it('holds a note for a device that is away, and drops its receipt', () => { + const first = sendFromViper(); + first.viper.emit('close'); + first.manager.reportDmOutcome({ dmRef: first.dmRef, outcome: 'unconfirmed' }); + expect(chats(first.eagle)).toEqual([]); + const back = connect(first.manager); + back.emit('data', Buffer.from(SELF('ANDROID-1', 'VIPER'))); + expect(remarks(back)[0]).toMatch(/^mesh-client: Not confirmed:/); + + const second = sendFromViper(); + second.viper.emit('close'); + second.manager.reportDmOutcome({ dmRef: second.dmRef, outcome: 'delivered' }); + const again = connect(second.manager); + again.emit('data', Buffer.from(SELF('ANDROID-1', 'VIPER'))); + expect(receipts(again)).toEqual([]); + }); + + it('ignores outcomes it is not waiting for, and forgets messages after half an hour', () => { + const { manager, viper, dmRef } = sendFromViper(); + manager.reportDmOutcome({ + dmRef: '00000000-0000-4000-8000-000000000000', + outcome: 'unconfirmed', + }); + expect(written(viper)).toEqual([]); + vi.setSystemTime(Date.now() + 30 * 60 * 1000); + manager.reportDmOutcome({ dmRef, outcome: 'unconfirmed' }); + expect(written(viper)).toEqual([]); + }); + }); }); diff --git a/src/main/tak-server-manager.ts b/src/main/tak-server-manager.ts index a6edd3e59..290175947 100644 --- a/src/main/tak-server-manager.ts +++ b/src/main/tak-server-manager.ts @@ -15,6 +15,7 @@ import type { TAKContactsUpdate, TAKDirectMessage, TakDmProtocol, + TakDmStatusUpdate, TAKGeochatMessage, TAKInboundChat, TakRelayExtras, @@ -57,6 +58,7 @@ import { buildChannelContactCot, buildDirectChatCot, buildDirectContactCot, + buildDirectReceiptCot, buildGeochatCot, CHANNEL_NOTE_SENDER, type ChannelChatLine, @@ -115,6 +117,54 @@ const DM_OFF_NOTE = const NOT_REACHABLE_NOTE = 'Not sent: not a mesh contact this radio can message.'; /** Companions remembered with the device that last wrote to them, and with a 0, 0 contact. */ const DIRECT_PEERS_MAX = 512; +/** Tries mesh-client makes at a direct message: the send and its automatic resends. */ +const DM_TRIES = 4; +/** + * How long a direct message from a TAK client waits for its outcome. Its resends are over + * within about five minutes; this also covers a slow last acknowledgement. + */ +const PENDING_DM_TTL_MS = 30 * 60 * 1000; +/** Direct messages from TAK clients awaiting their outcome. */ +const PENDING_DMS_MAX = 256; +/** How much of a message a note about it quotes. */ +const NOTE_QUOTE_LEN = 40; + +/** A direct message a TAK client sent, until the renderer reports how it went. */ +interface PendingDm { + /** The companion's contact uid. */ + uid: string; + protocol: TakDmProtocol; + nodeId: number; + /** Uid of the device that wrote it, which hears the outcome. */ + deviceUid: string; + /** The conversation name the device filed it under. */ + chatroom: string; + /** The device's id for the message, which a receipt names; unset when it sent none. */ + messageId?: string; + text: string; + sentAtMs: number; +} + +/** The note for a direct message that was not acknowledged, or never went out. */ +function dmOutcomeNote( + outcome: 'unconfirmed' | 'not-sent', + protocol: TakDmProtocol, + callsign: string, + text: string, +): string { + const quote = text.length > NOTE_QUOTE_LEN ? `${text.slice(0, NOTE_QUOTE_LEN)}…` : text; + if (outcome === 'not-sent') return `Not sent to the mesh: ${quote}`; + // MeshCore DMs fail both ways, every try, while either radio holds an old contact for the + // other; channels keep working since they share a key. A flood advert refreshes both. + const hint = + protocol === 'meshcore' + ? ' If this keeps happening, send a flood advert from each radio so both have current contacts.' + : ''; + return ( + `Not confirmed: no acknowledgement from ${callsign} after ${DM_TRIES} tries.` + + ` It may not have arrived: ${quote}${hint}` + ); +} /** * A heard message waiting for a TAK client to identify itself: a channel line for the first one @@ -181,6 +231,8 @@ export class TakServerManager extends EventEmitter { private directContacts = new Map(); /** Per companion uid, the uid of the TAK device that last sent it a direct message. */ private lastWriters = new Map(); + /** Direct messages from TAK clients awaiting their outcome, by dmRef, oldest first. */ + private pendingDms = new Map(); private chatRoomAnnounceTimer: ReturnType | null = null; private styleSettings: TakStyleSettings; @@ -1050,9 +1102,85 @@ export class TakServerManager extends EventEmitter { clientId: id, timeMs: nowMs, }; + if (client.selfUid) { + inbound.dmRef = randomUUID(); + this.prunePendingDms(nowMs); + const pending: PendingDm = { + uid, + protocol, + nodeId, + deviceUid: client.selfUid, + chatroom: chat.room, + text: chat.text, + sentAtMs: nowMs, + }; + if (chat.messageId) pending.messageId = chat.messageId; + setNewest(this.pendingDms, inbound.dmRef, pending, PENDING_DMS_MAX); + } this.emit('client-chat', inbound); } + /** + * Tell the device that wrote a direct message how it went: a delivery receipt when the + * companion acknowledged it, so ATAK marks it delivered, or a note in the conversation when + * it was not acknowledged after the last resend or never went out. A note for a device that + * is away waits for it with the held messages; a receipt is dropped. + */ + reportDmOutcome(update: TakDmStatusUpdate): void { + const now = Date.now(); + this.prunePendingDms(now); + const pending = this.pendingDms.get(update.dmRef); + if (!pending) return; + this.pendingDms.delete(update.dmRef); + if (!this._status.running) return; + const contact = { + uid: pending.uid, + callsign: this.directCallsign(pending.protocol, pending.nodeId, pending.chatroom), + }; + const device = this.clientWithUid(pending.deviceUid); + if (update.outcome === 'delivered') { + if (!device || pending.messageId === undefined) return; + const [id, client, recipient] = device; + this.writeToClient( + id, + client, + buildDirectReceiptCot(contact, pending.chatroom, recipient, pending.messageId, now), + ); + return; + } + const text = dmOutcomeNote(update.outcome, pending.protocol, contact.callsign, pending.text); + const line: DirectChatLine = { text: `${CHANNEL_NOTE_SENDER}: ${text}`, timeMs: now }; + if (device) { + const [id, client, recipient] = device; + this.writeToClient(id, client, buildDirectChatCot(contact, line, recipient, now)); + } else if (this.directContacts.has(pending.uid)) { + this.holdChat({ + kind: 'direct', + uid: pending.uid, + line, + recipientUid: pending.deviceUid, + heldAtMs: now, + }); + } + } + + /** Forget direct messages that have waited too long for their outcome. */ + private prunePendingDms(nowMs: number): void { + for (const [ref, pending] of this.pendingDms) { + if (nowMs - pending.sentAtMs < PENDING_DM_TTL_MS) return; + this.pendingDms.delete(ref); + } + } + + /** The identified local client whose own uid is `uid`, with its recipient details. */ + private clientWithUid(uid: string): [string, ConnectedClient, GeochatRecipient] | undefined { + for (const [id, client] of this.clients) { + const recipient = this.recipientFor(client); + if (recipient?.uid === uid) return [id, client, recipient]; + } + return undefined; + } + private _handleClient(socket: tls.TLSSocket): void { const address = socket.remoteAddress ?? 'unknown'; if (this.clients.size >= MAX_TAK_CLIENTS) { diff --git a/src/main/tak/geochat-cot.test.ts b/src/main/tak/geochat-cot.test.ts index f05518623..040682767 100644 --- a/src/main/tak/geochat-cot.test.ts +++ b/src/main/tak/geochat-cot.test.ts @@ -5,6 +5,7 @@ import { buildChannelContactCot, buildDirectChatCot, buildDirectContactCot, + buildDirectReceiptCot, buildGeochatCot, channelContactUid, geochatSenderUid, @@ -12,6 +13,7 @@ import { parseTakChatChannels, parseTakDirectMessage, parseTakDmProtocols, + parseTakDmStatus, parseTakRetiredRooms, retiredRoomContactUid, } from './geochat-cot'; @@ -240,6 +242,48 @@ describe('direct messages', () => { expect(() => parseTakDmProtocols(['reticulum'])).toThrow(/tak:setDirectMessaging/); expect(() => parseTakDmProtocols('meshcore')).toThrow(/tak:setDirectMessaging/); }); + + const REF = '0c2f8e1a-5b3d-4c7e-9a10-2b4d6f8a0c1e'; + + it('accepts a direct message outcome for a message reference', () => { + for (const outcome of ['delivered', 'unconfirmed', 'not-sent'] as const) { + expect(parseTakDmStatus({ dmRef: REF, outcome, extra: 1 })).toEqual({ dmRef: REF, outcome }); + } + for (const bad of [ + null, + 'delivered', + { dmRef: REF, outcome: 'read' }, + { dmRef: 'msg-1', outcome: 'delivered' }, + { dmRef: REF.toUpperCase(), outcome: 'delivered' }, + { outcome: 'delivered' }, + ]) { + expect(() => parseTakDmStatus(bad)).toThrow(/^tak:dmStatus: /); + } + }); +}); + +describe('buildDirectReceiptCot', () => { + it('is a b-t-f-d receipt for the message, from the companion, in the conversation as filed', () => { + const cot = buildDirectReceiptCot( + { uid: 'MC-123', callsign: 'Al & co' }, + 'Alice', + { uid: 'ANDROID-1', callsign: 'VIPER' }, + 'abc-123', + Date.UTC(2026, 9, 16, 12), + ); + expect(cot).toBe( + '' + + '' + + '' + + '<__chatreceipt parent="RootContactGroup" groupOwner="false" messageId="abc-123"' + + ' chatroom="Alice" id="MC-123" senderCallsign="Al & co">' + + '' + + '' + + '' + + '', + ); + }); }); describe('retired GeoChat rooms', () => { diff --git a/src/main/tak/geochat-cot.ts b/src/main/tak/geochat-cot.ts index 352d3190e..dfc34c9de 100644 --- a/src/main/tak/geochat-cot.ts +++ b/src/main/tak/geochat-cot.ts @@ -6,13 +6,16 @@ import { isTakDmProtocol, TAK_CHAT_CHANNELS_MAX, TAK_CHAT_ROOM_UID_PREFIX, + TAK_DM_OUTCOMES, TAK_GEOCHAT_CALLSIGN_MAX_LEN, TAK_GEOCHAT_ROOM_MAX_LEN, TAK_GEOCHAT_TEXT_MAX_LEN, type TAKChannelChatMessage, type TakChatChannel, type TAKDirectMessage, + type TakDmOutcome, type TakDmProtocol, + type TakDmStatusUpdate, type TAKGeochatMessage, } from '../../shared/tak-types'; import { escapeXml } from '../../shared/xmlEscape'; @@ -267,6 +270,22 @@ export function parseTakDmProtocols(raw: unknown): TakDmProtocol[] { return [...new Set(raw)]; } +const DM_REF_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** Validate an untrusted direct message outcome from the renderer. Throws when malformed. */ +export function parseTakDmStatus(raw: unknown): TakDmStatusUpdate { + const channel = 'tak:dmStatus'; + if (!raw || typeof raw !== 'object') throw new Error(`${channel}: update must be an object`); + const m = raw as Record; + if (typeof m.dmRef !== 'string' || !DM_REF_RE.test(m.dmRef)) { + throw new Error(`${channel}: dmRef must be a message reference`); + } + if (!(TAK_DM_OUTCOMES as readonly unknown[]).includes(m.outcome)) { + throw new Error(`${channel}: outcome must be one of ${TAK_DM_OUTCOMES.join(', ')}`); + } + return { dmRef: m.dmRef, outcome: m.outcome as TakDmOutcome }; +} + /** How long a companion keeps its contact without a marker after the last direct message. */ export const DIRECT_CONTACT_TTL_MS = 24 * 60 * 60 * 1000; @@ -327,6 +346,38 @@ export function buildDirectChatCot( ); } +/** + * Delivery receipt (`b-t-f-d`) for the message `messageId` a TAK client sent to a companion's + * contact: the companion acknowledged it, so ATAK marks it delivered. It describes that + * conversation as the client filed it (`chatroom`, the companion's uid as its id) and comes + * from the companion's contact. + */ +export function buildDirectReceiptCot( + contact: DirectChatContact, + chatroom: string, + recipient: GeochatRecipient, + messageId: string, + nowMs: number = Date.now(), +): string { + const time = new Date(nowMs).toISOString(); + const stale = new Date(nowMs + GEOCHAT_STALE_MS).toISOString(); + const uid = escapeXml(contact.uid); + const id = escapeXml(messageId); + return ( + `` + + `` + + `` + + `<__chatreceipt parent="RootContactGroup" groupOwner="false" messageId="${id}"` + + ` chatroom="${escapeXml(chatroom)}" id="${uid}" senderCallsign="${escapeXml(contact.callsign)}">` + + `` + + `` + + `` + + `` + + `` + ); +} + /** * Standard CoT v2.0 GeoChat (`b-t-f`) event for one message. `senderUid` is the sending device's * own uid when a TAK client's message is passed on to the other clients; a heard mesh message diff --git a/src/preload/index.ts b/src/preload/index.ts index 18f094d38..57d5cf1e7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -57,6 +57,7 @@ import type { TAKContactsUpdate, TAKDirectMessage, TakDmProtocol, + TakDmStatusUpdate, TAKEnrollmentRequest, TAKInboundChat, TAKNodeUpdate, @@ -1388,6 +1389,8 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('tak:pushDirectMessage', message), setDirectMessaging: (protocols: TakDmProtocol[]): Promise => ipcRenderer.invoke('tak:setDirectMessaging', protocols), + dmStatus: (update: TakDmStatusUpdate): Promise => + ipcRenderer.invoke('tak:dmStatus', update), getStyleSettings: (): Promise => ipcRenderer.invoke('tak:getStyleSettings'), setStyleSettings: (settings: TakStyleSettings): Promise => ipcRenderer.invoke('tak:setStyleSettings', settings), diff --git a/src/renderer/hooks/useTakInboundChat.ts b/src/renderer/hooks/useTakInboundChat.ts index 2cf695b9d..01cec0049 100644 --- a/src/renderer/hooks/useTakInboundChat.ts +++ b/src/renderer/hooks/useTakInboundChat.ts @@ -4,6 +4,7 @@ import type { TakChatChannel, TakDmProtocol } from '@/shared/tak-types'; import { errLikeToLogString } from '../lib/errLikeToLogString'; import { listTakDmProtocols } from '../lib/tak/takDirectMessages'; +import { startTakDmStatus } from '../lib/tak/takDmStatus'; import { enqueueTakChatRelay } from '../lib/tak/takInboundChat'; import { sendTakTextOnMesh } from '../lib/tak/takMeshSend'; import { @@ -104,6 +105,9 @@ export function useTakInboundChat(sources: TakChannelSources): void { setTakChannels(meshcoreId, meshcoreInfos); }, [meshcoreId, meshcoreInfos]); + // Tell each device how its direct messages went, as their automatic resends settle. + useEffect(() => startTakDmStatus(), []); + useEffect(() => { setTakRadio('meshcore', meshcoreId); setTakRadio('meshtastic', meshtasticId); diff --git a/src/renderer/lib/autoResend/autoResendController.test.ts b/src/renderer/lib/autoResend/autoResendController.test.ts index 432071736..aa4316bad 100644 --- a/src/renderer/lib/autoResend/autoResendController.test.ts +++ b/src/renderer/lib/autoResend/autoResendController.test.ts @@ -16,6 +16,7 @@ import { isAutoResendEligible, resetAutoResendForTests, startAutoResend, + subscribeAutoResendOutcomes, useAutoResendStore, } from './autoResendController'; import { AUTO_RESEND_DELAYS_MS, REGULAR_MESSAGE_MAX_AUTO_RESENDS } from './autoResendPolicy'; @@ -180,4 +181,61 @@ describe('startAutoResend', () => { setStatus(identityId, '555', 'failed'); expect(useAutoResendStore.getState().entries[autoResendKey('meshtastic', '555')]).toBeDefined(); }); + + describe('outcomes', () => { + let outcomes: unknown[]; + beforeEach(() => { + outcomes = []; + subscribeAutoResendOutcomes((o) => outcomes.push(o)); + }); + + it('reports an ack under the first send, through a re-key and a resend', () => { + const identityId = identityFor('meshtastic'); + addMessage(identityId, dm('1')); + renameMessageId(identityId, '1', '555'); + setStatus(identityId, '555', 'failed'); + vi.advanceTimersByTime(AUTO_RESEND_DELAYS_MS[0]); + expect(outcomes).toEqual([]); + setStatus(identityId, String(nextId), 'acked'); + expect(outcomes).toEqual([ + { identityId, protocol: 'meshtastic', originId: '1', outcome: 'acked' }, + ]); + }); + + it('reports giving up once the last resend has failed too', () => { + const identityId = identityFor('meshcore'); + addMessage(identityId, dm('1')); + let currentId = '1'; + for (let attempt = 1; attempt <= REGULAR_MESSAGE_MAX_AUTO_RESENDS; attempt += 1) { + setStatus(identityId, currentId, 'failed'); + vi.advanceTimersByTime(AUTO_RESEND_DELAYS_MS[attempt - 1]); + currentId = String(nextId); + } + expect(outcomes).toEqual([]); + setStatus(identityId, currentId, 'failed'); + expect(outcomes).toEqual([ + { identityId, protocol: 'meshcore', originId: '1', outcome: 'gave-up' }, + ]); + }); + + it('reports giving up when a resend cannot start', () => { + const identityId = identityFor('meshtastic'); + send.mockReturnValueOnce(undefined); + addMessage(identityId, dm('1')); + setStatus(identityId, '1', 'failed'); + vi.advanceTimersByTime(AUTO_RESEND_DELAYS_MS[0]); + expect(outcomes).toEqual([ + { identityId, protocol: 'meshtastic', originId: '1', outcome: 'gave-up' }, + ]); + }); + + it('reports nothing for a cancelled retry', () => { + const identityId = identityFor('meshtastic'); + addMessage(identityId, dm('1')); + setStatus(identityId, '1', 'failed'); + cancelAutoResend('meshtastic', '1'); + vi.advanceTimersByTime(60 * 60 * 1000); + expect(outcomes).toEqual([]); + }); + }); }); diff --git a/src/renderer/lib/autoResend/autoResendController.ts b/src/renderer/lib/autoResend/autoResendController.ts index f7bad0c12..299c42ed5 100644 --- a/src/renderer/lib/autoResend/autoResendController.ts +++ b/src/renderer/lib/autoResend/autoResendController.ts @@ -66,6 +66,49 @@ interface TrackedOutbound { protocol: MeshProtocol; /** Automatic resends already spent on this message's lineage. */ resendsUsed: number; + /** Store id of the lineage's first send, before any re-key or resend. */ + originId: string; +} + +/** + * How a tracked send's lineage ended: acknowledged, or given up on (still failed after the last + * automatic resend, or a resend that could not start). A manual Resend or a cancelled retry + * ends it without an outcome. + */ +export interface AutoResendOutcome { + identityId: IdentityId; + protocol: MeshProtocol; + /** Store id the lineage's first send had when it was made. */ + originId: string; + outcome: 'acked' | 'gave-up'; +} + +const outcomeListeners = new Set<(outcome: AutoResendOutcome) => void>(); + +/** Hear how each tracked send's lineage ends. Returns an unsubscribe. */ +export function subscribeAutoResendOutcomes( + listener: (outcome: AutoResendOutcome) => void, +): () => void { + outcomeListeners.add(listener); + return () => { + outcomeListeners.delete(listener); + }; +} + +function emitOutcome(info: TrackedOutbound, outcome: AutoResendOutcome['outcome']): void { + const event: AutoResendOutcome = { + identityId: info.identityId, + protocol: info.protocol, + originId: info.originId, + outcome, + }; + for (const listener of outcomeListeners) { + try { + listener(event); + } catch (err: unknown) { + console.warn('[autoResend] outcome listener failed ' + errLikeToLogString(err)); + } + } } /** Bound on tracked session sends; oldest are dropped first (Map insertion order). */ @@ -147,19 +190,30 @@ function fireResend(key: string): void { const record = recordFor(entry.identityId, entry.messageId); // Superseded meanwhile (manual Resend, Reticulum announce resend, late ACK). if (record?.status !== 'failed') return; + const info: TrackedOutbound = tracked.get(key) ?? { + identityId: entry.identityId, + protocol: entry.protocol, + resendsUsed: entry.attempt - 1, + originId: entry.messageId, + }; tracked.delete(key); let newId: string | undefined; try { newId = sendFn(entry.protocol, resendArgsFor(record)); } catch (err: unknown) { console.warn('[autoResend] resend failed to start ' + errLikeToLogString(err)); + emitOutcome(info, 'gave-up'); + return; + } + if (newId == null || newId === '') { + emitOutcome(info, 'gave-up'); return; } - if (newId == null || newId === '') return; trackOutbound(autoResendKey(entry.protocol, newId), { identityId: entry.identityId, protocol: entry.protocol, resendsUsed: entry.attempt, + originId: info.originId, }); if (newId !== entry.messageId) { removeSupersededBubble(entry.protocol, entry.identityId, entry.messageId); @@ -193,11 +247,13 @@ export function evaluateTrackedOutbound(now: number = Date.now()): void { if (record.status === 'acked') { tracked.delete(key); clearEntry(key); + emitOutcome(info, 'acked'); continue; } if (record.status !== 'failed' || Object.hasOwn(entries, key)) continue; if (info.resendsUsed >= REGULAR_MESSAGE_MAX_AUTO_RESENDS) { tracked.delete(key); + emitOutcome(info, 'gave-up'); continue; } schedule(key, info, messageId, now); @@ -245,7 +301,12 @@ export function startAutoResend( if (protocol == null || !isAutoResendEligible(protocol, record)) return; const key = autoResendKey(protocol, record.id); if (!tracked.has(key)) { - trackOutbound(key, { identityId: event.identityId, protocol, resendsUsed: 0 }); + trackOutbound(key, { + identityId: event.identityId, + protocol, + resendsUsed: 0, + originId: record.id, + }); } }); const unsubStore = useMessageStore.subscribe(() => { @@ -263,6 +324,7 @@ export function resetAutoResendForTests(): void { for (const timer of timers.values()) clearTimeout(timer); timers.clear(); tracked.clear(); + outcomeListeners.clear(); sendFn = null; resolveProtocol = () => null; useAutoResendStore.setState({ entries: {} }); diff --git a/src/renderer/lib/devElectronApiStub.ts b/src/renderer/lib/devElectronApiStub.ts index a755aa4aa..3d4523371 100644 --- a/src/renderer/lib/devElectronApiStub.ts +++ b/src/renderer/lib/devElectronApiStub.ts @@ -403,6 +403,7 @@ export function createDevElectronApiStub(): typeof window.electronAPI { retireChatRooms: async () => {}, pushDirectMessage: async () => {}, setDirectMessaging: async () => {}, + dmStatus: async () => {}, onInboundChat: noopUnsub, }, bleCoexistence: { diff --git a/src/renderer/lib/tak/takDmStatus.test.ts b/src/renderer/lib/tak/takDmStatus.test.ts new file mode 100644 index 000000000..3084b0511 --- /dev/null +++ b/src/renderer/lib/tak/takDmStatus.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { addMessage, type MessageRecord, useMessageStore } from '../../stores/messageStore'; +import { + type AutoResendSendFn, + resetAutoResendForTests, + startAutoResend, +} from '../autoResend/autoResendController'; +import { AUTO_RESEND_DELAYS_MS } from '../autoResend/autoResendPolicy'; +import { + reportTakDmOutcome, + resetTakDmStatus, + startTakDmStatus, + watchTakDirectMessage, +} from './takDmStatus'; + +const ID = 'tak-dm-status-mt'; +const REF = '0c2f8e1a-5b3d-4c7e-9a10-2b4d6f8a0c1e'; + +function dm(id: string): MessageRecord { + return { + id, + from: 1, + to: 456, + payload: '[VIPER] hi', + channelIndex: 0, + timestamp: Date.now(), + status: 'sending', + }; +} + +function setStatus(id: string, status: MessageRecord['status']): void { + useMessageStore.setState((s) => { + const bucket = s.messages[ID] ?? {}; + return { messages: { ...s.messages, [ID]: { ...bucket, [id]: { ...bucket[id], status } } } }; + }); +} + +describe('takDmStatus', () => { + let dmStatus: ReturnType; + let stop: () => void; + let nextId = 100; + + beforeEach(() => { + vi.useFakeTimers(); + useMessageStore.setState({ messages: {} }); + dmStatus = vi.spyOn(window.electronAPI.tak, 'dmStatus').mockResolvedValue(undefined); + // The API mock is shared by every test, so earlier calls are cleared. + dmStatus.mockClear(); + vi.spyOn(window.electronAPI.db, 'deleteFailedOutboundMessage').mockResolvedValue({ + changes: 1, + }); + // Each resend is a new message; the controller follows it back to the first send. + const send: AutoResendSendFn = () => { + nextId += 1; + addMessage(ID, dm(String(nextId))); + return String(nextId); + }; + const stopResend = startAutoResend(send, (id) => (id === ID ? 'meshtastic' : null)); + const stopStatus = startTakDmStatus(); + stop = () => { + stopStatus(); + stopResend(); + }; + }); + + afterEach(() => { + stop(); + resetAutoResendForTests(); + resetTakDmStatus(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('reports a watched message delivered when a resend of it is acknowledged', () => { + addMessage(ID, dm('1')); + watchTakDirectMessage(ID, '1', REF); + setStatus('1', 'failed'); + vi.advanceTimersByTime(AUTO_RESEND_DELAYS_MS[0]); + expect(dmStatus).not.toHaveBeenCalled(); + setStatus(String(nextId), 'acked'); + expect(dmStatus).toHaveBeenCalledExactlyOnceWith({ dmRef: REF, outcome: 'delivered' }); + }); + + it('reports it unconfirmed once the last resend has failed', () => { + addMessage(ID, dm('1')); + watchTakDirectMessage(ID, '1', REF); + let current = '1'; + for (const delay of AUTO_RESEND_DELAYS_MS) { + setStatus(current, 'failed'); + vi.advanceTimersByTime(delay); + current = String(nextId); + } + setStatus(current, 'failed'); + expect(dmStatus).toHaveBeenCalledExactlyOnceWith({ dmRef: REF, outcome: 'unconfirmed' }); + }); + + it('says nothing about messages it is not watching', () => { + addMessage(ID, dm('1')); + setStatus('1', 'acked'); + expect(dmStatus).not.toHaveBeenCalled(); + }); + + it('logs a report main refused', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + dmStatus.mockRejectedValueOnce(new Error('rate limited')); + reportTakDmOutcome(REF, 'not-sent'); + await vi.runAllTimersAsync(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('dmStatus failed')); + }); +}); diff --git a/src/renderer/lib/tak/takDmStatus.ts b/src/renderer/lib/tak/takDmStatus.ts new file mode 100644 index 000000000..7c3572d48 --- /dev/null +++ b/src/renderer/lib/tak/takDmStatus.ts @@ -0,0 +1,62 @@ +import type { TakDmOutcome } from '@/shared/tak-types'; + +import { subscribeAutoResendOutcomes } from '../autoResend/autoResendController'; +import { errLikeToLogString } from '../errLikeToLogString'; +import type { IdentityId } from '../types'; + +/** + * How long a direct message from a TAK client is watched. Its automatic resends are over within + * about five minutes; main gives up waiting at the same point. + */ +const WATCH_TTL_MS = 30 * 60 * 1000; +const WATCH_MAX = 256; + +/** Direct messages from TAK clients being watched, by identity and first store id, oldest first. */ +const watched = new Map(); + +function watchKey(identityId: IdentityId, storeId: string): string { + return `${identityId}:${storeId}`; +} + +/** Tell main how a TAK client's direct message ended, so its device hears about it. */ +export function reportTakDmOutcome(dmRef: string, outcome: TakDmOutcome): void { + window.electronAPI.tak.dmStatus({ dmRef, outcome }).catch((e: unknown) => { + console.warn('[TakDmStatus] dmStatus failed ' + errLikeToLogString(e)); + }); +} + +/** + * Watch a direct message a TAK client sent, stored as `storeId` on `identityId`, until its + * automatic resends end: acknowledged, or still not after the last one. + */ +export function watchTakDirectMessage( + identityId: IdentityId, + storeId: string, + dmRef: string, +): void { + const now = Date.now(); + for (const [key, entry] of watched) { + if (now - entry.sinceMs < WATCH_TTL_MS && watched.size < WATCH_MAX) break; + watched.delete(key); + } + watched.set(watchKey(identityId, storeId), { dmRef, sinceMs: now }); +} + +/** + * Report the outcome of each watched direct message to main as the resend controller settles + * it. Mount once while TAK chat runs; returns a teardown. + */ +export function startTakDmStatus(): () => void { + return subscribeAutoResendOutcomes((event) => { + const key = watchKey(event.identityId, event.originId); + const entry = watched.get(key); + if (!entry) return; + watched.delete(key); + reportTakDmOutcome(entry.dmRef, event.outcome === 'acked' ? 'delivered' : 'unconfirmed'); + }); +} + +/** Forget watched messages; for tests. */ +export function resetTakDmStatus(): void { + watched.clear(); +} diff --git a/src/renderer/lib/tak/takInboundChat.test.ts b/src/renderer/lib/tak/takInboundChat.test.ts index 17f552aba..4113df3f7 100644 --- a/src/renderer/lib/tak/takInboundChat.test.ts +++ b/src/renderer/lib/tak/takInboundChat.test.ts @@ -240,4 +240,45 @@ describe('direct messages from TAK', () => { ); expect(consumeTakEcho('mt', 0, '[VIPER] on scene', NOW + 1000)).toBe(false); }); + + describe('outcomes', () => { + const DM_REF = '0c2f8e1a-5b3d-4c7e-9a10-2b4d6f8a0c1e'; + const dmStatus = vi.fn().mockResolvedValue(undefined); + beforeEach(() => { + dmStatus.mockClear(); + vi.stubGlobal('window', { electronAPI: { tak: { dmStatus } } }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('hands the message reference on to the send, for its outcome', async () => { + setTakRadio('meshtastic', 'mt'); + useTakRelayPrefsStore.getState().setDirectMessages('mt', true); + const send = vi.fn().mockResolvedValue(undefined); + await relayTakChatToMesh( + message({ target: direct('meshtastic', 456), dmRef: DM_REF }), + send, + NOW, + ); + expect(send.mock.calls[0]?.[0]).toMatchObject({ destination: 456, dmRef: DM_REF }); + expect(dmStatus).not.toHaveBeenCalled(); + }); + + it('reports a direct message not sent when the send fails or no radio can send it', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + setTakRadio('meshtastic', 'mt'); + useTakRelayPrefsStore.getState().setDirectMessages('mt', true); + const failing = vi.fn().mockRejectedValue(new Error('rf down')); + const msg = message({ target: direct('meshtastic', 456), dmRef: DM_REF }); + expect(await relayTakChatToMesh(msg, failing, NOW)).toBe(0); + expect(dmStatus).toHaveBeenLastCalledWith({ dmRef: DM_REF, outcome: 'not-sent' }); + + dmStatus.mockClear(); + useTakRelayPrefsStore.getState().setDirectMessages('mt', false); + expect(await relayTakChatToMesh(msg, failing, NOW)).toBe(0); + expect(dmStatus).toHaveBeenCalledWith({ dmRef: DM_REF, outcome: 'not-sent' }); + warn.mockRestore(); + }); + }); }); diff --git a/src/renderer/lib/tak/takInboundChat.ts b/src/renderer/lib/tak/takInboundChat.ts index b709c6e9a..28c28d9aa 100644 --- a/src/renderer/lib/tak/takInboundChat.ts +++ b/src/renderer/lib/tak/takInboundChat.ts @@ -4,6 +4,7 @@ import { getTakRadio } from '../../stores/takChannelStore'; import { getTakRelayPrefs } from '../../stores/takRelayPrefsStore'; import { errLikeToLogString } from '../errLikeToLogString'; import type { IdentityId } from '../types'; +import { reportTakDmOutcome } from './takDmStatus'; import { consumeTakEcho, rememberTakSend } from './takEchoGuard'; import { listTakServedChannels } from './takServedChannels'; @@ -56,6 +57,8 @@ export interface TakSendTarget { channelIndex: number; /** The companion a direct message goes to; unset for a channel message. */ destination?: number; + /** Main's id for a direct message, under which its outcome is reported. */ + dmRef?: string; } /** @@ -106,8 +109,14 @@ export async function relayTakChatToMesh( sendFn: TakMeshSendFn, nowMs: number = Date.now(), ): Promise { - const targets = findTakSendTargets(message.target); - if (targets.length === 0) return 0; + const targets = findTakSendTargets(message.target).map((t) => + t.destination !== undefined && message.dmRef ? { ...t, dmRef: message.dmRef } : t, + ); + if (targets.length === 0) { + // Main let it through, so the device is waiting to hear how it went. + if (message.dmRef) reportTakDmOutcome(message.dmRef, 'not-sent'); + return 0; + } const text = formatTakMeshText(message.senderCallsign, message.text); let sent = 0; for (const target of targets) { @@ -119,6 +128,7 @@ export async function relayTakChatToMesh( sent++; } catch (e) { if (onChannel) consumeTakEcho(target.identityId, target.channelIndex, text, nowMs); + if (target.dmRef) reportTakDmOutcome(target.dmRef, 'not-sent'); console.warn('[TakInboundChat] mesh send failed ' + errLikeToLogString(e)); } } diff --git a/src/renderer/lib/tak/takMeshSend.test.ts b/src/renderer/lib/tak/takMeshSend.test.ts index 684daa926..d118d8a0f 100644 --- a/src/renderer/lib/tak/takMeshSend.test.ts +++ b/src/renderer/lib/tak/takMeshSend.test.ts @@ -9,6 +9,7 @@ import { useMessageStore, } from '../../stores/messageStore'; import { useNodeStore } from '../../stores/nodeStore'; +import { resetAutoResendForTests, startAutoResend } from '../autoResend/autoResendController'; import { connectionDriver } from '../drivers/ConnectionDriver'; import { resetHeardRepeatWindowsForTests } from '../meshcore/heardRepeatTracker'; import { meshcoreProtocol } from '../protocols/MeshCoreProtocol'; @@ -20,6 +21,7 @@ import { registerMeshtasticSession, } from '../sessions/meshtasticSession'; import { mockConsoleWarn } from '../vitestConsoleMock'; +import { resetTakDmStatus, startTakDmStatus } from './takDmStatus'; import { sendTakTextOnMesh, waitForOutboundSettled } from './takMeshSend'; vi.mock('../drivers/ConnectionDriver', () => ({ @@ -148,6 +150,35 @@ describe('sendTakTextOnMesh', () => { ]); }); + it('reports the direct message delivered to TAK once the companion acknowledges it', async () => { + vi.spyOn(meshcoreProtocol, 'sendMessage').mockImplementation(() => new Promise(() => {})); + vi.mocked(connectionDriver.getHandle).mockReturnValue({ kind: 'rf' }); + const dmStatus = vi.spyOn(window.electronAPI.tak, 'dmStatus').mockResolvedValue(undefined); + addMeshcoreIdentity(); + const stopResend = startAutoResend( + () => undefined, + (id) => (id === ID_MC ? 'meshcore' : null), + ); + const stopStatus = startTakDmStatus(); + const dmRef = '0c2f8e1a-5b3d-4c7e-9a10-2b4d6f8a0c1e'; + + await sendTakTextOnMesh( + { identityId: ID_MC, protocol: 'meshcore', channelIndex: 0, destination: 123, dmRef }, + '[VIPER] meet at the gate', + ); + expect(dmStatus).not.toHaveBeenCalled(); + const [row] = rows(ID_MC); + useMessageStore.setState((s) => ({ + messages: { ...s.messages, [ID_MC]: { [row.id]: { ...row, status: 'acked' } } }, + })); + expect(dmStatus).toHaveBeenCalledWith({ dmRef, outcome: 'delivered' }); + + stopStatus(); + stopResend(); + resetAutoResendForTests(); + resetTakDmStatus(); + }); + it('rejects when the MeshCore send fails, leaving the message failed in Chat', async () => { mockConsoleWarn(); vi.spyOn(meshcoreProtocol, 'sendMessage').mockRejectedValue(new Error('rf down')); diff --git a/src/renderer/lib/tak/takMeshSend.ts b/src/renderer/lib/tak/takMeshSend.ts index be6d193ba..329fe128a 100644 --- a/src/renderer/lib/tak/takMeshSend.ts +++ b/src/renderer/lib/tak/takMeshSend.ts @@ -5,6 +5,7 @@ import { } from '../../stores/messageStore'; import { sendChatOnIdentity } from '../sendChatOnIdentity'; import type { IdentityId } from '../types'; +import { watchTakDirectMessage } from './takDmStatus'; import { TAK_MESH_SEND_TIMEOUT_MS, type TakMeshSendFn } from './takInboundChat'; export type OutboundSettlement = 'acked' | 'failed' | 'timeout'; @@ -71,6 +72,9 @@ export const sendTakTextOnMesh: TakMeshSendFn = async (target, text) => { ...(target.destination !== undefined ? { destination: target.destination } : {}), }); if (storeId == null) throw new Error(`${target.protocol} send was not started`); + if (target.destination !== undefined && target.dmRef) { + watchTakDirectMessage(target.identityId, storeId, target.dmRef); + } // A MeshCore DM stays `sending` until its ack, which can take longer than the send queue // should wait; delivery is tracked by the message store. if (target.protocol === 'meshtastic' || target.destination !== undefined) return; diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 3ce58cda1..3477ea872 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -6804,7 +6804,7 @@ "channelRelayNoChannelsMeshtastic": "Connect a Meshtastic radio to list its channels.", "channelRelayUnnamed": "Channel {{n}}", "directMessages": "Direct messages", - "directMessagesHint": "Let ATAK devices message this radio's companions. Each companion this radio can message gets a chat option in ATAK, and a message to it goes out from this radio, prefixed with the sender's callsign. Its replies go to the ATAK device that last wrote to it, or to every device when none has. A companion with no position appears at 0, 0 for a day after a message." + "directMessagesHint": "Let ATAK devices message this radio's companions. Each companion this radio can message gets a chat option in ATAK, and a message to it goes out from this radio, prefixed with the sender's callsign. ATAK marks it delivered when the companion acknowledges it, or shows a note if it is still unacknowledged after the automatic resends. Its replies go to the ATAK device that last wrote to it, or to every device when none has. A companion with no position appears at 0, 0 for a day after a message." }, "telemetryPanel": { "title": "Telemetry", diff --git a/src/renderer/vitest.electronApiMock.ts b/src/renderer/vitest.electronApiMock.ts index 4e0014c06..147ec6eec 100644 --- a/src/renderer/vitest.electronApiMock.ts +++ b/src/renderer/vitest.electronApiMock.ts @@ -510,6 +510,7 @@ export function createElectronAPIMock(): ElectronAPI { retireChatRooms: vi.fn().mockResolvedValue(undefined), pushDirectMessage: vi.fn().mockResolvedValue(undefined), setDirectMessaging: vi.fn().mockResolvedValue(undefined), + dmStatus: vi.fn().mockResolvedValue(undefined), onInboundChat: vi.fn().mockReturnValue(() => {}), }, bleCoexistence: { diff --git a/src/shared/electron-api.types.ts b/src/shared/electron-api.types.ts index 8db15c5a5..ccfdac516 100644 --- a/src/shared/electron-api.types.ts +++ b/src/shared/electron-api.types.ts @@ -79,6 +79,7 @@ import type { TAKContactsUpdate, TAKDirectMessage, TakDmProtocol, + TakDmStatusUpdate, TAKEnrollmentRequest, TAKInboundChat, TAKNodeUpdate, @@ -1468,6 +1469,11 @@ export interface ElectronAPI { * started. */ setDirectMessaging: (protocols: TakDmProtocol[]) => Promise; + /** + * Report how a TAK client's direct message ended, so its device gets a receipt or a note; + * dropped with no sink. + */ + dmStatus: (update: TakDmStatusUpdate) => Promise; /** How relayed nodes are styled: user unit filters over each node's advertised role. */ getStyleSettings: () => Promise; /** Validate, save, and apply style settings; resolves with what was saved. */ diff --git a/src/shared/tak-types.ts b/src/shared/tak-types.ts index 8c8690af3..10ff1cc29 100644 --- a/src/shared/tak-types.ts +++ b/src/shared/tak-types.ts @@ -290,6 +290,25 @@ export interface TAKInboundChat { clientId: string; /** When the server received it, epoch milliseconds. */ timeMs: number; + /** + * For a direct message: main's id for it, handed back with {@link TakDmStatusUpdate} so the + * device that wrote hears how it went. + */ + dmRef?: string; +} + +/** + * How a direct message from a TAK client ended: acknowledged by the companion, still not + * acknowledged after the last automatic resend, or never sent at all. + */ +export const TAK_DM_OUTCOMES = ['delivered', 'unconfirmed', 'not-sent'] as const; +export type TakDmOutcome = (typeof TAK_DM_OUTCOMES)[number]; + +/** The outcome of one direct message from a TAK client, for main. */ +export interface TakDmStatusUpdate { + /** {@link TAKInboundChat.dmRef} of the message. */ + dmRef: string; + outcome: TakDmOutcome; } /** A direct message heard from a companion, for TAK clients. */ diff --git a/vitest.config.mts b/vitest.config.mts index 2999613f6..6d828da10 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -100,6 +100,7 @@ const RENDERER_LOGIC_EXCLUDE = [ 'src/renderer/lib/tak/takServedChannels.test.ts', 'src/renderer/lib/tak/takMeshSend.test.ts', 'src/renderer/lib/tak/takDirectMessages.test.ts', + 'src/renderer/lib/tak/takDmStatus.test.ts', 'src/renderer/lib/letsMeshJwt.test.ts', 'src/renderer/lib/messageRetention.test.ts', 'src/renderer/lib/rrcMessagePersist.test.ts', From 0361042dcebdbefe7383145e8cf2169184c94730 Mon Sep 17 00:00:00 2001 From: "Robert M. Talbot" Date: Fri, 16 Oct 2026 17:18:50 -0600 Subject: [PATCH 4/4] fix(tak): never send a direct message from a radio no longer in use The radio TAK uses per protocol is recorded in an effect, so for a moment after a switch to another radio of the same protocol it can still name the old one, whose transport may still be open. findTakSendTargets now drops a direct target whose recorded radio is not the protocol's current identity. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EHFPfW7vZXpRdR3tBBTX3r --- src/renderer/lib/tak/takInboundChat.test.ts | 17 +++++++++++++++++ src/renderer/lib/tak/takInboundChat.ts | 4 ++++ 2 files changed, 21 insertions(+) diff --git a/src/renderer/lib/tak/takInboundChat.test.ts b/src/renderer/lib/tak/takInboundChat.test.ts index 17f552aba..e71811bbd 100644 --- a/src/renderer/lib/tak/takInboundChat.test.ts +++ b/src/renderer/lib/tak/takInboundChat.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MeshProtocol } from '@/shared/meshProtocol'; import type { TAKInboundChat } from '@/shared/tak-types'; import { @@ -20,6 +21,12 @@ import { type TakMeshSendFn, } from './takInboundChat'; +/** The identity each protocol's tab uses now, as the identity store resolves it. */ +const currentIdentity: Partial> = { meshcore: 'mc', meshtastic: 'mt' }; +vi.mock('../identityByProtocol', () => ({ + getIdentityIdForProtocol: (protocol: MeshProtocol) => currentIdentity[protocol] ?? null, +})); + const NOW = Date.parse('2026-10-11T12:00:00Z'); const OPS = '0123456789abcdef'; const OTHER = 'fedcba9876543210'; @@ -227,6 +234,16 @@ describe('direct messages from TAK', () => { expect(findTakSendTargets(direct('meshcore', 123))).toEqual([]); }); + it('sends nothing while the recorded radio is no longer the one in use for the protocol', () => { + setTakRadio('meshcore', 'mc'); + useTakRelayPrefsStore.getState().setDirectMessages('mc', true); + // Another MeshCore radio has just become active; the recorded radio catches up a render later. + currentIdentity.meshcore = 'mc-2'; + expect(findTakSendTargets(direct('meshcore', 123))).toEqual([]); + currentIdentity.meshcore = 'mc'; + expect(findTakSendTargets(direct('meshcore', 123))).toHaveLength(1); + }); + it('sends the prefixed text as a direct message, with no echo to wait for', async () => { setTakRadio('meshtastic', 'mt'); useTakRelayPrefsStore.getState().setDirectMessages('mt', true); diff --git a/src/renderer/lib/tak/takInboundChat.ts b/src/renderer/lib/tak/takInboundChat.ts index b709c6e9a..a9021e143 100644 --- a/src/renderer/lib/tak/takInboundChat.ts +++ b/src/renderer/lib/tak/takInboundChat.ts @@ -3,6 +3,7 @@ import type { TakChatTarget, TAKInboundChat } from '@/shared/tak-types'; import { getTakRadio } from '../../stores/takChannelStore'; import { getTakRelayPrefs } from '../../stores/takRelayPrefsStore'; import { errLikeToLogString } from '../errLikeToLogString'; +import { getIdentityIdForProtocol } from '../identityByProtocol'; import type { IdentityId } from '../types'; import { consumeTakEcho, rememberTakSend } from './takEchoGuard'; import { listTakServedChannels } from './takServedChannels'; @@ -67,6 +68,9 @@ export function findTakSendTargets(target: TakChatTarget): TakSendTarget[] { if (target.kind === 'direct') { const identityId = getTakRadio(target.protocol); if (!identityId || !getTakRelayPrefs(identityId).directMessages) return []; + // The radio is recorded after a render, so for a moment after a switch to another radio of + // the same protocol it can still name the old one; never send from that. + if (identityId !== getIdentityIdForProtocol(target.protocol)) return []; return [{ identityId, protocol: target.protocol, channelIndex: 0, destination: target.nodeId }]; } return listTakServedChannels()