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 e71811bbd..1a31fa815 100644 --- a/src/renderer/lib/tak/takInboundChat.test.ts +++ b/src/renderer/lib/tak/takInboundChat.test.ts @@ -257,4 +257,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 a9021e143..7ebc6f2b6 100644 --- a/src/renderer/lib/tak/takInboundChat.ts +++ b/src/renderer/lib/tak/takInboundChat.ts @@ -5,6 +5,7 @@ import { getTakRelayPrefs } from '../../stores/takRelayPrefsStore'; import { errLikeToLogString } from '../errLikeToLogString'; import { getIdentityIdForProtocol } from '../identityByProtocol'; import type { IdentityId } from '../types'; +import { reportTakDmOutcome } from './takDmStatus'; import { consumeTakEcho, rememberTakSend } from './takEchoGuard'; import { listTakServedChannels } from './takServedChannels'; @@ -57,6 +58,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; } /** @@ -110,8 +113,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) { @@ -123,6 +132,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',