Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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. 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.
- **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, a Reticulum LXMF peer whose delivery address is known) can be messaged from its marker in ATAK. Reticulum has its own **Reticulum messaging in ATAK** section for this; an LXMF peer with no position appears at 0, 0 only once mesh-client has a conversation with it, so ATAK's contact list does not fill with every peer heard. 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 @@ -457,6 +457,7 @@ describe('GPS/TAK IPC sender validation (source contract)', () => {
'tak:pushDirectMessage',
'tak:setDirectMessaging',
'tak:dmStatus',
'tak:setDirectPeers',
] as const;

it.each(takChannels)('tak handler %s calls assertIpcSender', (channel) => {
Expand Down
11 changes: 11 additions & 0 deletions src/main/ipc/tak-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,17 @@ describe('direct message handlers', () => {
expect(idle).not.toHaveBeenCalled();
});

it('hands the conversation peers to the manager, once it exists', async () => {
const setDirectPeers = vi.fn();
const handler = await register({ setDirectPeers }, 'tak:setDirectPeers');
const peer = { protocol: 'reticulum', nodeId: 789, callsign: 'Field Laptop' };
handler({}, [peer]);
expect(setDirectPeers).toHaveBeenCalledWith([peer]);
expect(assertIpcSender).toHaveBeenCalledWith({}, 'tak:setDirectPeers');
expect(() => handler({}, 'peers')).toThrow(/tak:setDirectPeers/);
expect((await register(null, 'tak:setDirectPeers'))({}, [])).toBeUndefined();
});

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
15 changes: 15 additions & 0 deletions src/main/ipc/tak-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
parseTakChannelChatMessage,
parseTakChatChannels,
parseTakDirectMessage,
parseTakDirectPeers,
parseTakDmProtocols,
parseTakDmStatus,
parseTakRetiredRooms,
Expand Down Expand Up @@ -309,6 +310,20 @@ export function registerTakIpcHandlers(deps: TakIpcDeps): void {
m.setDirectMessaging(parseTakDmProtocols(protocols));
});

const directPeerUpdates = createIpcRateLimiter({
max: 60,
windowMs: MS_PER_MINUTE,
label: 'tak:setDirectPeers',
});
ipcMain.handle('tak:setDirectPeers', (event, peers: unknown) => {
assertIpcSender(event, 'tak:setDirectPeers');
// As for channels: the renderer sends the list again when a sink starts.
const m = getTakServerManager();
if (!m) return;
directPeerUpdates.checkOrThrow();
m.setDirectPeers(parseTakDirectPeers(peers));
});

const dmStatusUpdates = createIpcRateLimiter({
max: 120,
windowMs: MS_PER_MINUTE,
Expand Down
78 changes: 73 additions & 5 deletions src/main/tak-server-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1222,10 +1222,10 @@ describe('TakServerManager direct messages', () => {
vi.useRealTimers();
});

/** A manager with the local server running and direct messages on for both radios. */
/** A manager with the local server running and direct messages on for every radio. */
function dmManager(): TakServerManager {
const manager = chatManager([]);
manager.setDirectMessaging(['meshcore', 'meshtastic']);
manager.setDirectMessaging(['meshcore', 'meshtastic', 'reticulum']);
return manager;
}

Expand Down Expand Up @@ -1343,12 +1343,16 @@ describe('TakServerManager direct messages', () => {
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 [
[0, 'MESH-456'],
[3, 'RN-42'],
] as const) {
expect(notes[i]).toContain(`uid0="${uid}"`);
expect(notes[i]).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(
Expand Down Expand Up @@ -1494,6 +1498,70 @@ describe('TakServerManager direct messages', () => {
expect(chats(watcher)).toEqual([]);
});

describe('conversation peers', () => {
const PEER = { protocol: 'reticulum' as const, nodeId: 789, callsign: 'Field Laptop' };
const contactFor = (socket: tls.TLSSocket, uid: string) =>
written(socket).filter((l) => l.includes(`uid="${uid}"`) && l.includes('a-f-G-U-C'));

it('gives a peer with no marker a contact at 0, 0, and takes it away when dropped', () => {
const manager = dmManager();
const socket = connect(manager, ['ANDROID-1', 'VIPER']);
manager.setDirectPeers([PEER]);
const [contact] = contactFor(socket, 'RN-789');
expect(contact).toContain('<point lat="0" lon="0"');
expect(contact).toContain('<contact callsign="Field Laptop" endpoint="*:-1:stcp"/>');

// The same list again changes nothing.
vi.mocked(socket.write).mockClear();
manager.setDirectPeers([PEER]);
expect(written(socket)).toEqual([]);

// A device connecting later gets it with the channel contacts.
const late = mockTlsSocket();
(manager as unknown as ChatInternals)._handleClient(late);
expect(contactFor(late, 'RN-789')).toHaveLength(1);

manager.setDirectPeers([]);
expect(written(socket).some((l) => l.includes('uid="RN-789"') && l.includes('t-x-d-d'))).toBe(
true,
);
});

it('lets a device message a conversation peer, which has no marker', () => {
const manager = dmManager();
manager.setDirectPeers([PEER]);
const inbound = listen(manager);
const viper = connect(manager, ['ANDROID-1', 'VIPER']);
viper.emit('data', Buffer.from(DM('carry on', 'RN-789', { room: 'Field Laptop' })));
expect(inbound.map((m) => m.target)).toEqual([
{ kind: 'direct', protocol: 'reticulum', nodeId: 789 },
]);
});

it('lets the marker stand in for a peer that has one', () => {
const manager = dmManager();
const socket = connect(manager, ['ANDROID-1', 'VIPER']);
manager.onNodeUpdate({ ...ALICE, protocol: 'reticulum', node_id: 789, chatReachable: false });
vi.mocked(socket.write).mockClear();
manager.setDirectPeers([PEER]);
expect(written(socket)).toEqual([]);
});

it('shows no contact while direct messages are off for the radio, and adds it when on', () => {
const manager = chatManager([]);
manager.setDirectMessaging(['meshcore']);
const socket = connect(manager, ['ANDROID-1', 'VIPER']);
manager.setDirectPeers([PEER]);
expect(contactFor(socket, 'RN-789')).toEqual([]);
manager.setDirectMessaging(['meshcore', 'reticulum']);
expect(contactFor(socket, 'RN-789')).toHaveLength(1);
manager.setDirectMessaging(['meshcore']);
expect(written(socket).some((l) => l.includes('uid="RN-789"') && l.includes('t-x-d-d'))).toBe(
true,
);
});
});

describe('outcomes', () => {
const receipts = (socket: tls.TLSSocket) =>
written(socket).filter((l) => l.includes('type="b-t-f-d"'));
Expand Down
75 changes: 72 additions & 3 deletions src/main/tak-server-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
TAKContact,
TAKContactsUpdate,
TAKDirectMessage,
TakDirectPeer,
TakDmProtocol,
TakDmStatusUpdate,
TAKGeochatMessage,
Expand Down Expand Up @@ -181,6 +182,14 @@ type HeldChat =
heldAtMs: number;
};

/** A peer this app already has a direct conversation with. */
interface ConversationPeer {
uid: string;
protocol: TakDmProtocol;
nodeId: number;
callsign: string;
}

/** A companion a direct message has passed with, either way, in the last day. */
interface DirectContact {
uid: string;
Expand Down Expand Up @@ -233,6 +242,11 @@ export class TakServerManager extends EventEmitter {
private lastWriters = new Map<string, string>();
/** Direct messages from TAK clients awaiting their outcome, by dmRef, oldest first. */
private pendingDms = new Map<string, PendingDm>();
/**
* Peers the renderer already has a direct conversation with, by uid. Each is messageable and,
* while it has no marker, has a contact at 0, 0.
*/
private conversationPeers = new Map<string, ConversationPeer>();
private chatRoomAnnounceTimer: ReturnType<typeof setInterval> | null = null;
private styleSettings: TakStyleSettings;

Expand Down Expand Up @@ -493,7 +507,17 @@ export class TakServerManager extends EventEmitter {
this.heldChat = this.heldChat.filter(
(h) => h.kind === 'channel' || this.directContacts.has(h.uid),
);
const previous = this.dmProtocols;
this.dmProtocols = next;
for (const peer of this.conversationPeers.values()) {
if (next.has(peer.protocol) === previous.has(peer.protocol)) continue;
if (this.hasFreshMarker(peer.protocol, peer.nodeId, now)) continue;
this.writeToClients(
next.has(peer.protocol)
? this.conversationPeerCot(peer, now)
: cotDeleteEvent(peer.uid, now),
);
}
const cutoff = now - COT_STALE_MS;
for (const node of this.nodeCache.values()) {
if (node.cachedAtMs < cutoff || !changed.includes(node.protocol)) continue;
Expand All @@ -503,6 +527,40 @@ export class TakServerManager extends EventEmitter {
this.scheduleChatRoomAnnouncements();
}

/**
* Replace the peers the renderer has a direct conversation with. A peer with no marker gets
* its contact at 0, 0 at once, while direct messages are on for its radio; one dropped from
* the list loses it, unless a direct message passed with it in the last day.
*/
setDirectPeers(peers: TakDirectPeer[]): void {
const now = Date.now();
const next = new Map<string, ConversationPeer>();
for (const peer of peers) {
const uid = cotUidFor({ node_id: peer.nodeId }, peer.protocol);
next.set(uid, { uid, ...peer });
}
const shown = (peer: ConversationPeer): boolean =>
this.dmProtocols.has(peer.protocol) &&
!this.directContacts.has(peer.uid) &&
!this.hasFreshMarker(peer.protocol, peer.nodeId, now);
for (const old of this.conversationPeers.values()) {
if (!next.has(old.uid) && shown(old)) this.writeToClients(cotDeleteEvent(old.uid, now));
}
for (const peer of next.values()) {
const old = this.conversationPeers.get(peer.uid);
if (old?.callsign === peer.callsign || !shown(peer)) continue;
this.writeToClients(this.conversationPeerCot(peer, now));
}
this.conversationPeers = next;
this.scheduleChatRoomAnnouncements();
}

/** A conversation peer's contact at 0, 0, under the name its marker would have. */
private conversationPeerCot(peer: ConversationPeer, nowMs: number): string {
const callsign = this.directCallsign(peer.protocol, peer.nodeId, peer.callsign);
return buildDirectContactCot({ uid: peer.uid, callsign }, nowMs, nowMs);
}

/** 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);
Expand Down Expand Up @@ -558,7 +616,10 @@ export class TakServerManager extends EventEmitter {
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));
const uid = cotUidFor(node, node.protocol);
return (
node.chatReachable === true || this.directContacts.has(uid) || this.conversationPeers.has(uid)
);
}

/** Whether local clients show the node's own marker now, standing in for its contact. */
Expand Down Expand Up @@ -675,7 +736,8 @@ export class TakServerManager extends EventEmitter {
* expire in ATAK. Runs while there is any contact to announce.
*/
private scheduleChatRoomAnnouncements(): void {
const needed = this.chatChannels.size > 0 || this.directContacts.size > 0;
const needed =
this.chatChannels.size > 0 || this.directContacts.size > 0 || this.conversationPeers.size > 0;
if (needed === (this.chatRoomAnnounceTimer !== null)) return;
if (this.chatRoomAnnounceTimer) {
clearInterval(this.chatRoomAnnounceTimer);
Expand Down Expand Up @@ -708,6 +770,12 @@ export class TakServerManager extends EventEmitter {
send(buildDirectContactCot(contact, contact.lastDmMs, now));
}
}
for (const peer of this.conversationPeers.values()) {
if (!this.dmProtocols.has(peer.protocol) || this.directContacts.has(peer.uid)) continue;
if (!this.hasFreshMarker(peer.protocol, peer.nodeId, now)) {
send(this.conversationPeerCot(peer, now));
}
}
}

/** The uid and callsign a client reported for itself, once it has reported both. */
Expand Down Expand Up @@ -1089,7 +1157,8 @@ export class TakServerManager extends EventEmitter {
return;
}
const node = this.cachedNodeFor(protocol, nodeId);
if (!this.directContacts.has(uid) && !(node && this.isChatReachable(node))) {
const known = this.directContacts.has(uid) || this.conversationPeers.has(uid);
if (!known && !(node && this.isChatReachable(node))) {
note(NOT_REACHABLE_NOTE);
return;
}
Expand Down
26 changes: 23 additions & 3 deletions src/main/tak/geochat-cot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
parseTakChannelChatMessage,
parseTakChatChannels,
parseTakDirectMessage,
parseTakDirectPeers,
parseTakDmProtocols,
parseTakDmStatus,
parseTakRetiredRooms,
Expand Down Expand Up @@ -220,7 +221,7 @@ describe('direct messages', () => {
});
for (const bad of [
null,
{ ...msg, protocol: 'reticulum' },
{ ...msg, protocol: 'rrc' },
{ ...msg, nodeId: 0 },
{ ...msg, nodeId: 2 ** 32 },
{ ...msg, nodeId: 1.5 },
Expand All @@ -234,15 +235,34 @@ describe('direct messages', () => {
});

it('accepts the radios with direct messages on, without repeats', () => {
expect(parseTakDmProtocols(['meshcore', 'meshtastic', 'meshcore'])).toEqual([
expect(parseTakDmProtocols(['meshcore', 'meshtastic', 'reticulum', 'meshcore'])).toEqual([
'meshcore',
'meshtastic',
'reticulum',
]);
expect(parseTakDmProtocols([])).toEqual([]);
expect(() => parseTakDmProtocols(['reticulum'])).toThrow(/tak:setDirectMessaging/);
expect(() => parseTakDmProtocols(['rrc'])).toThrow(/tak:setDirectMessaging/);
expect(() => parseTakDmProtocols('meshcore')).toThrow(/tak:setDirectMessaging/);
});

it('accepts conversation peers, without repeats, and rejects malformed ones', () => {
const peer = { protocol: 'reticulum', nodeId: 789, callsign: ' Field Laptop ' };
expect(parseTakDirectPeers([peer, { ...peer, callsign: 'again' }])).toEqual([
{ protocol: 'reticulum', nodeId: 789, callsign: 'Field Laptop' },
]);
expect(parseTakDirectPeers([])).toEqual([]);
for (const bad of [
'peers',
[null],
[{ ...peer, protocol: 'rrc' }],
[{ ...peer, nodeId: 0 }],
[{ ...peer, callsign: 'a\nb' }],
Array.from({ length: 65 }, (_, i) => ({ ...peer, nodeId: i + 1 })),
]) {
expect(() => parseTakDirectPeers(bad)).toThrow(/^tak:setDirectPeers: /);
}
});

const REF = '0c2f8e1a-5b3d-4c7e-9a10-2b4d6f8a0c1e';

it('accepts a direct message outcome for a message reference', () => {
Expand Down
Loading
Loading