Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
16 changes: 8 additions & 8 deletions docs/reticulum-sidecar-ipc.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,14 @@ Routing bias between **RF** (LoRa / RNode) and **network** (TCP/UDP/I2P/gateway/

### LXMF and contacts

| Method | Path | Body / notes | Response |
| ------ | ------------------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| POST | `/api/v1/lxmf/send` | `{ destination_hash, text, reply_to_hash?, reply_to_id?, reply_preview_text? }` | Live: stamps LXMF `FIELD_REPLY_TO` (0x30) / optional `FIELD_REPLY_QUOTE` (0x31) before sign; `{ ok, delivery_method?, delivery_status?, sent_via?, message? }` or `{ ok: false, error: "no_propagation_node" }`. **`delivery_status` on this response is initial enqueue state only** (`queued` or `sending`) — not delivery confirmation. Stub: `{ ok, sent_via?, message? }` |
| POST | `/api/v1/lxmf/reaction` | `{ destination_hash, target_hash, emoji }` | `{ ok, message? }` |
| GET | `/api/v1/lxmf/recent` | `?since_ts=` (ms, optional), `?limit=` (default 200, max 500) | `{ messages: [], ring_len }` — ring buffer of recent **inbound** LXMF payloads for WS lag/reconnect catch-up (not durable across sidecar restart; capped at 200); `ring_len` is current buffer occupancy |
| DELETE | `/api/v1/lxmf/messages/{hash}` | | `{ ok }` |
| GET | `/api/v1/contacts` | | `{ contacts: [] }` — overlays announce/peer/Nomad labels onto nameless or hash-prefix contact `display_name` values (does not overwrite a real name) and may persist fills |
| DELETE | `/api/v1/contacts` | | `{ ok, cleared }` — clears LXMF contacts after demoting them into the peer cache (keeps Peers; does not delete chat messages) |
| Method | Path | Body / notes | Response |
| ------ | ------------------------------ | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| POST | `/api/v1/lxmf/send` | `{ destination_hash, text, reply_to_hash?, reply_to_id?, reply_preview_text? }` | Live: stamps LXMF `FIELD_REPLY_TO` (0x30) / optional `FIELD_REPLY_QUOTE` (0x31) before sign; `{ ok, delivery_method?, delivery_status?, sent_via?, message? }` or `{ ok: false, error: "no_propagation_node" }`. **`delivery_status` on this response is initial enqueue state only** (`queued` or `sending`) — not delivery confirmation. Stub: `{ ok, sent_via?, message? }` |
| POST | `/api/v1/lxmf/reaction` | `{ destination_hash, target_hash, emoji }` | `{ ok, message? }` |
| GET | `/api/v1/lxmf/recent` | `?since_ts=` (ms, optional, **exclusive** lower bound), `?limit=` (default 200, max 500) | `{ messages: [], ring_len }` — ring buffer of recent **inbound** LXMF payloads for WS lag/reconnect catch-up (not durable across sidecar restart; capped at 200); `since_ts` keeps rows with `timestamp > since_ts` so a watermark equal to the newest ingested ts does not re-return that boundary forever; `ring_len` is current buffer occupancy |
| DELETE | `/api/v1/lxmf/messages/{hash}` | | `{ ok }` |
| GET | `/api/v1/contacts` | | `{ contacts: [] }` — overlays announce/peer/Nomad labels onto nameless or hash-prefix contact `display_name` values (does not overwrite a real name) and may persist fills |
| DELETE | `/api/v1/contacts` | | `{ ok, cleared }` — clears LXMF contacts after demoting them into the peer cache (keeps Peers; does not delete chat messages) |

### Peers, topology, and propagation

Expand Down
2 changes: 1 addition & 1 deletion reticulum-sidecar/src/api/lxmf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ pub async fn lxmf_delete_message(

#[derive(Debug, Deserialize)]
pub struct RecentLxmfQuery {
/// Inclusive lower bound on payload `timestamp` (ms). Omit to return the full ring.
/// Exclusive lower bound on payload `timestamp` (ms). Omit to return the full ring.
#[serde(default)]
pub since_ts: Option<i64>,
/// Max rows (default 200, capped at 500).
Expand Down
49 changes: 42 additions & 7 deletions reticulum-sidecar/src/stack/lxmf_inbound_log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,12 @@ impl LxmfInboundBuffer {
self.inner.lock().map(|buf| buf.len()).unwrap_or(0)
}

/// Snapshot newest-first filtered by optional `since_ts` (inclusive, ms), then reverse to
/// chronological order for ingest catch-up.
/// Snapshot newest-first filtered by optional `since_ts` (exclusive lower bound, ms),
/// then reverse to chronological order for ingest catch-up.
///
/// Exclusive (`ts > since_ts`) so a watermark equal to the newest ingested timestamp
/// does not re-return that boundary row on every periodic catch-up.
/// Same-ms twins at exactly `since_ts` are skipped (accepted tradeoff vs inclusive loop).
pub fn snapshot(&self, since_ts: Option<i64>, limit: usize) -> Vec<serde_json::Value> {
let limit = limit.max(1);
let Ok(buf) = self.inner.lock() else {
Expand All @@ -62,7 +66,7 @@ impl LxmfInboundBuffer {
Some(min_ts) => row
.get("timestamp")
.and_then(serde_json::Value::as_i64)
.is_some_and(|ts| ts >= min_ts),
.is_some_and(|ts| ts > min_ts),
})
.cloned()
.collect();
Expand Down Expand Up @@ -101,14 +105,45 @@ mod tests {
}

#[test]
fn since_ts_filters_and_limit_keeps_newest() {
fn since_ts_filters_exclusive_and_limit_keeps_newest() {
let buf = LxmfInboundBuffer::new(10);
buf.push(msg("h1", 100, "a"));
buf.push(msg("h2", 200, "b"));
buf.push(msg("h3", 300, "c"));
let rows = buf.snapshot(Some(200), 2);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0]["message_hash"], "h2");
assert_eq!(rows[1]["message_hash"], "h3");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["message_hash"], "h3");
}

#[test]
fn since_ts_at_boundary_returns_empty() {
let buf = LxmfInboundBuffer::new(10);
buf.push(msg("h2", 200, "b"));
let rows = buf.snapshot(Some(200), 10);
assert!(rows.is_empty());
}

#[test]
fn since_ts_none_returns_full_chronological_buffer() {
let buf = LxmfInboundBuffer::new(10);
buf.push(msg("h1", 100, "a"));
buf.push(msg("h2", 200, "b"));
buf.push(msg("h3", 300, "c"));
let rows = buf.snapshot(None, 10);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0]["message_hash"], "h1");
assert_eq!(rows[1]["message_hash"], "h2");
assert_eq!(rows[2]["message_hash"], "h3");
}

#[test]
fn same_ms_twins_excluded_at_exact_since_ts() {
let buf = LxmfInboundBuffer::new(10);
buf.push(msg("h_a", 200, "a"));
buf.push(msg("h_b", 200, "b"));
let below = buf.snapshot(Some(199), 10);
assert_eq!(below.len(), 2);
let at = buf.snapshot(Some(200), 10);
assert!(at.is_empty());
}
}
11 changes: 8 additions & 3 deletions src/renderer/components/ConnectionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1275,7 +1275,8 @@ export default function ConnectionPanel({
} else {
errorMsg = err instanceof Error ? err.message : t('connectionPanel.error.connectionFailed');
}
setError(errorMsg);
// Empty humanize = MeshCore setup AbortError (supersede/cancel); do not setError('').
if (errorMsg) setError(errorMsg);
setConnecting(false);
setConnectionStage('');
}
Expand Down Expand Up @@ -1760,7 +1761,9 @@ export default function ConnectionPanel({
setShowSerialPicker(false);
setConnectionStage('connectionPanel.stagePleaseWait');
onConnect('http', addr).catch((err: unknown) => {
setError(humanizeHttpError(addr, err, t));
// Empty humanize = MeshCore setup AbortError (supersede/cancel); do not setError('').
const httpErr = humanizeHttpError(addr, err, t);
if (httpErr) setError(httpErr);
setConnecting(false);
setConnectionStage('');
});
Expand All @@ -1775,7 +1778,9 @@ export default function ConnectionPanel({
setShowSerialPicker(false);
setConnectionStage('connectionPanel.stagePleaseWait');
onConnect('tcp', addr).catch((err: unknown) => {
setError(humanizeHttpError(addr, err, t));
// Empty humanize = MeshCore setup AbortError (supersede/cancel); do not setError('').
const tcpErr = humanizeHttpError(addr, err, t);
if (tcpErr) setError(tcpErr);
setConnecting(false);
setConnectionStage('');
});
Expand Down
52 changes: 44 additions & 8 deletions src/renderer/lib/connectionPanelErrorHumanize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,12 @@ describe('hostFromAddressInput / isMeshtasticLocalAddress', () => {
expect(hostFromAddressInput('http://meshtastic.local')).toBe('meshtastic.local');
expect(isMeshtasticLocalAddress('meshtastic.local')).toBe(true);
expect(isMeshtasticLocalAddress('node.meshtastic.local')).toBe(true);
expect(isMeshtasticLocalAddress('192.168.1.10')).toBe(true);
expect(isMeshtasticLocalAddress('fd00::1')).toBe(true);
expect(isMeshtasticLocalAddress('fe80::1')).toBe(true);
expect(isMeshtasticLocalAddress('::1')).toBe(true);
expect(isMeshtasticLocalAddress('radio.local')).toBe(true);
// Regression #610: private/ULA/loopback are local hosts but not mDNS — Bonjour copy must not apply.
expect(isMeshtasticLocalAddress('192.168.1.10')).toBe(false);
expect(isMeshtasticLocalAddress('fd00::1')).toBe(false);
expect(isMeshtasticLocalAddress('fe80::1')).toBe(false);
expect(isMeshtasticLocalAddress('::1')).toBe(false);
expect(isMeshtasticLocalAddress('8.8.8.8')).toBe(false);
expect(isMeshtasticLocalAddress('2001:db8::1')).toBe(false);
});
Expand Down Expand Up @@ -93,7 +95,9 @@ describe('humanizeHttpError', () => {
'timeoutMdnsWindows',
],
['mdns non-windows timeout', 'linux', 'meshtastic.local', 'timeout', 'timeoutMdnsNonWindows'],
['local private ip timeout', 'linux', '192.168.1.10', 'aborted', 'timeoutMdnsNonWindows'],
['private ip timeout linux', 'linux', '192.168.1.10', 'aborted', 'timeoutGeneric'],
['private ip timeout darwin', 'darwin', '192.168.4.35', 'timeout', 'timeoutGeneric'],
['private ip timeout win32', 'win32', '192.168.1.10', 'timed out', 'timeoutGeneric'],
['public ip timeout', 'linux', '8.8.8.8', 'aborted', 'timeoutGeneric'],
['unauthorized', 'linux', '192.168.1.10', '401 unauthorized', 'unauthorizedHint'],
['refused', 'linux', '192.168.1.10', 'ECONNREFUSED', 'econnrefusedHint'],
Expand All @@ -104,16 +108,48 @@ describe('humanizeHttpError', () => {
expect(result).toContain(`connectionPanel.humanize.http.${hintKey}`);
});

it('adds local-network suffix on non-timeout errors for LAN addresses', () => {
// Regression #610: do not re-broaden isMeshtasticLocalAddress to isLocalConnectHost.
it.each(['win32', 'darwin', 'linux'] as const)(
'does not attach Bonjour/mDNS timeout hints to private IPs on %s',
(platform) => {
mockPlatform(platform);
const result = humanizeHttpError('192.168.4.35', new Error('connection timed out'), t);
expect(result).toContain('timeoutGeneric');
expect(result).not.toContain('timeoutMdnsWindows');
expect(result).not.toContain('timeoutMdnsNonWindows');
expect(result).not.toContain('suffixMdnsWindows');
expect(result).not.toContain('suffixMdnsNonWindows');
},
);

it('adds mDNS suffix on non-timeout errors for .local hosts only', () => {
mockPlatform('win32');
const result = humanizeHttpError('192.168.1.10', new Error('weird failure'), t);
expect(result).toContain('suffixMdnsWindows');
const mdnsResult = humanizeHttpError('meshtastic.local', new Error('weird failure'), t);
expect(mdnsResult).toContain('suffixMdnsWindows');

mockPlatform('linux');
const mdnsNonWin = humanizeHttpError('meshtastic.local', new Error('weird failure'), t);
expect(mdnsNonWin).toContain('suffixMdnsNonWindows');
});

it('returns raw message for private-IP non-timeout errors (no Bonjour suffix)', () => {
mockPlatform('win32');
expect(humanizeHttpError('192.168.1.10', new Error('weird failure'), t)).toBe('weird failure');
});

it('returns raw message for generic public IP errors', () => {
mockPlatform('linux');
expect(humanizeHttpError('8.8.8.8', new Error('weird failure'), t)).toBe('weird failure');
});

it.each(['win32', 'darwin'] as const)(
'suppresses MeshCore setup AbortError on private IP (%s)',
(platform) => {
mockPlatform(platform);
const err = new DOMException(MESHCORE_SETUP_ABORT_MESSAGE, 'AbortError');
expect(humanizeHttpError('192.168.4.35', err, t)).toBe('');
},
);
});

describe('humanizeBleError', () => {
Expand Down
13 changes: 10 additions & 3 deletions src/renderer/lib/connectionPanelErrorHumanize.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { TFunction } from 'i18next';

import {
isLocalConnectHost,
isMdnsConnectHost,
parseConnectHostPort,
stripConnectHostBrackets,
} from '@/shared/connectHost';
Expand All @@ -28,9 +28,9 @@ export function hostFromAddressInput(address: string): string {
}
}

/** True only for *.local / meshtastic.local — not private IPs (do not use isLocalConnectHost; #610). */
export function isMeshtasticLocalAddress(address: string): boolean {
const host = hostFromAddressInput(address);
return isLocalConnectHost(host);
return isMdnsConnectHost(hostFromAddressInput(address));
}

type RuntimePlatform = 'linux' | 'darwin' | 'win32' | 'unknown';
Expand Down Expand Up @@ -85,7 +85,14 @@ export function humanizeSerialError(err: unknown, t: TFunction): string {
}

export function humanizeHttpError(address: string, err: unknown, t: TFunction): string {
// Same contract as humanizeBleError: intentional MeshCore setup supersede/cancel is not a
// user-facing HTTP/TCP failure — do not attach Bonjour/LAN network hints.
if (isMeshcoreSetupAbortError(err)) {
return '';
}
const msg = err instanceof Error ? err.message : String(err);
// Bonjour / "try the IP" copy only for real mDNS hosts. Do not switch to isLocalConnectHost —
// #610 did and showed meshtastic.local Bonjour advice for MeshCore TCP private IPs.
const isMdns = isMeshtasticLocalAddress(address);
const platform = runtimePlatform();
const isWindows = platform === 'win32';
Expand Down
Loading