Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<set UID>/<group>/<file>`) 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
Expand Down
1 change: 1 addition & 0 deletions src/main/index.ipc-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
18 changes: 18 additions & 0 deletions src/main/ipc/tak-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
14 changes: 14 additions & 0 deletions src/main/ipc/tak-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
parseTakChatChannels,
parseTakDirectMessage,
parseTakDmProtocols,
parseTakDmStatus,
parseTakRetiredRooms,
} from '../tak/geochat-cot';
import { parseTakNodeUpdate } from '../tak/node-update';
Expand Down Expand Up @@ -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) => {
Expand Down
96 changes: 96 additions & 0 deletions src/main/tak-server-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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('<chatgrp uid0="ANDROID-1" uid1="MC-123" id="MC-123"/>');
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([]);
});
});
});
128 changes: 128 additions & 0 deletions src/main/tak-server-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
TAKContactsUpdate,
TAKDirectMessage,
TakDmProtocol,
TakDmStatusUpdate,
TAKGeochatMessage,
TAKInboundChat,
TakRelayExtras,
Expand Down Expand Up @@ -57,6 +58,7 @@ import {
buildChannelContactCot,
buildDirectChatCot,
buildDirectContactCot,
buildDirectReceiptCot,
buildGeochatCot,
CHANNEL_NOTE_SENDER,
type ChannelChatLine,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -181,6 +231,8 @@ export class TakServerManager extends EventEmitter {
private directContacts = new Map<string, DirectContact>();
/** Per companion uid, the uid of the TAK device that last sent it a direct message. */
private lastWriters = new Map<string, string>();
/** Direct messages from TAK clients awaiting their outcome, by dmRef, oldest first. */
private pendingDms = new Map<string, PendingDm>();
private chatRoomAnnounceTimer: ReturnType<typeof setInterval> | null = null;
private styleSettings: TakStyleSettings;

Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading