Dorian d514e0e5e4 fix(mesh): DM-via-channel tunnel + disable presence spam
Meshcore direct unicast silently drops between our two Archy nodes
(firmware reports flood sends with resp_code=6 but nothing arrives).
Wrap DMs as channel-1 broadcasts with a [0xD1][dest_prefix(6)][inner]
header; receivers filter by prefix and dispatch the inner payload
through the existing typed/base64/chunk ladder. Shrink chunk body to
125B so the wrapper still fits the 160B LoRa budget. Auto-heal
routing: CMD_RESET_PATH (0x0D) any type-1 contact with path_len=0 on
refresh so floods take over. send_text now returns the firmware's
flood/direct mode flag for diagnostics.

Disable the 120s presence heartbeat broadcaster — its CBOR payload
was being re-echoed as plaintext by the shared repeater, spamming
every visible node with garbled "Archy-…: av�…fstatusfonline…"
messages on channel 0. mesh.broadcast-presence RPC stays registered
but no longer transmits. Re-enable only once presence moves off the
shared broadcast path.

Also: MeshState.cmd_tx behind RwLock so stop()→start() cycles don't
fail with "command channel already consumed"; MeshService.send_cmd
helper; drop_message_by_id for control envelopes that shouldn't
appear as Sent bubbles; self_advert_name reflected into MeshStatus
after set; path_len/flags parsed out of RESP_CONTACT.

Frontend: unified inbox merges mesh peers with federation nodes by
DID/pubkey/name; hide presence/read_receipt/edit/channel_invite/
contact_card from chat stream; publicChannel index → 1 to match the
new DM-via-channel routing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:24:27 -04:00

657 lines
19 KiB
TypeScript

// Pinia store for mesh networking state (Meshcore LoRa)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { rpcClient } from '@/api/rpc-client'
export interface MeshStatus {
enabled: boolean
device_type: string
device_path: string | null
device_connected: boolean
firmware_version: string | null
self_node_id: number | null
self_advert_name: string | null
peer_count: number
channel_name: string
messages_sent: number
messages_received: number
detected_devices?: string[]
}
export interface MeshPeer {
contact_id: number
advert_name: string
did: string | null
pubkey_hex: string | null
rssi: number | null
snr: number | null
last_heard: string
hops: number
}
export interface MeshChannel {
index: number
name: string
has_secret: boolean
}
export type MeshMessageTypeLabel =
| 'text'
| 'alert'
| 'invoice'
| 'psbt_hash'
| 'coordinate'
| 'block_header'
| 'tx_relay'
| 'tx_relay_response'
| 'tx_confirmation'
| 'lightning_relay'
| 'lightning_relay_response'
| 'content_ref'
| 'reply'
| 'reaction'
| 'read_receipt'
| 'forward'
| 'edit'
| 'delete'
| 'presence'
| 'channel_invite'
| 'contact_card'
export interface MeshMessage {
id: number
direction: 'sent' | 'received'
peer_contact_id: number
peer_name: string | null
plaintext: string
timestamp: string
delivered: boolean
encrypted: boolean
message_type?: MeshMessageTypeLabel
// eslint-disable-next-line @typescript-eslint/no-explicit-any
typed_payload?: Record<string, any> | null
/// Cross-transport identity for this message — (sender_pubkey, sender_seq)
/// forms a stable MessageKey used by replies/reactions.
sender_pubkey?: string | null
sender_seq?: number | null
}
export interface InvoiceData {
bolt11: string
amount_sats: number
memo: string | null
payment_hash?: string
paid?: boolean
}
export interface AlertData {
alert_type: 'emergency' | 'status' | 'dead_man' | 'block_header'
message: string
coordinate?: { lat: number; lng: number; label?: string }
signed?: boolean
}
export interface CoordinateData {
lat: number
lng: number
label?: string
}
export interface SessionStatus {
has_session: boolean
forward_secrecy: boolean
message_count: number
ratchet_generation: number
peer_did: string | null
}
export interface AlertStatus {
dead_man_enabled: boolean
dead_man_interval_secs: number
triggered: boolean
time_remaining_secs: number
has_gps: boolean
emergency_contacts: number
}
export interface BlockHeader {
height: number
hash: string
prev_hash: string
timestamp: number
announced_by: string
}
export interface NodePosition {
lat: number
lng: number
label?: string
timestamp: string
}
export const useMeshStore = defineStore('mesh', () => {
const status = ref<MeshStatus | null>(null)
const peers = ref<MeshPeer[]>([])
const messages = ref<MeshMessage[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const sending = ref(false)
// Serialize send operations to prevent concurrent fetchMessages() races
let sendQueue: Promise<void> = Promise.resolve()
// Node position tracking for map view (contact_id -> position)
const nodePositions = ref<Map<number, NodePosition>>(new Map())
// Track unread message counts per peer (contact_id -> count)
const unreadCounts = ref<Record<number, number>>({})
// Currently viewing chat for this contact_id (clears unread)
const viewingChatId = ref<number | null>(null)
// Total unread count for nav badge
const totalUnread = computed(() =>
Object.values(unreadCounts.value).reduce((a, b) => a + b, 0)
)
async function fetchStatus() {
try {
loading.value = true
error.value = null
const res = await rpcClient.call<MeshStatus>({ method: 'mesh.status' })
status.value = res
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status'
} finally {
loading.value = false
}
}
async function fetchPeers() {
try {
const res = await rpcClient.call<{ peers: MeshPeer[]; count: number }>({
method: 'mesh.peers',
})
peers.value = res.peers
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh peers'
}
}
async function fetchMessages(limit?: number) {
try {
const res = await rpcClient.call<{ messages: MeshMessage[]; count: number }>({
method: 'mesh.messages',
params: limit ? { limit } : {},
})
// Detect new incoming messages and increment unread counts
const newMsgs = res.messages.filter(
m => m.direction === 'received' && !messages.value.some(existing => existing.id === m.id)
)
for (const msg of newMsgs) {
// Don't count as unread if we're currently viewing that chat
if (msg.peer_contact_id !== viewingChatId.value) {
unreadCounts.value[msg.peer_contact_id] = (unreadCounts.value[msg.peer_contact_id] || 0) + 1
}
}
messages.value = res.messages
// Extract node positions from coordinate messages
updateNodePositionsFromMessages(res.messages)
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh messages'
}
}
// Convert microdegrees (from mesh protocol) to degrees for Leaflet
// Values > 90 for lat or > 180 for lng indicate microdegrees
function toDegreesIfMicro(lat: number, lng: number): { lat: number; lng: number } {
if (Math.abs(lat) > 90 || Math.abs(lng) > 180) {
return { lat: lat / 1000000, lng: lng / 1000000 }
}
return { lat, lng }
}
function updateNodePositionsFromMessages(msgs: MeshMessage[]) {
for (const msg of msgs) {
if (msg.message_type === 'coordinate' && msg.typed_payload) {
const payload = msg.typed_payload as CoordinateData
if (typeof payload.lat === 'number' && typeof payload.lng === 'number') {
const existing = nodePositions.value.get(msg.peer_contact_id)
if (!existing || msg.timestamp > existing.timestamp) {
const deg = toDegreesIfMicro(payload.lat, payload.lng)
nodePositions.value.set(msg.peer_contact_id, {
lat: deg.lat,
lng: deg.lng,
label: payload.label,
timestamp: msg.timestamp,
})
}
}
}
// Also extract coordinates from alert messages that include location
if (msg.message_type === 'alert' && msg.typed_payload) {
const payload = msg.typed_payload as AlertData
if (payload.coordinate && typeof payload.coordinate.lat === 'number' && typeof payload.coordinate.lng === 'number') {
const existing = nodePositions.value.get(msg.peer_contact_id)
if (!existing || msg.timestamp > existing.timestamp) {
const deg = toDegreesIfMicro(payload.coordinate.lat, payload.coordinate.lng)
nodePositions.value.set(msg.peer_contact_id, {
lat: deg.lat,
lng: deg.lng,
label: payload.coordinate.label,
timestamp: msg.timestamp,
})
}
}
}
}
}
function getNodePositions(): Map<number, NodePosition> {
return nodePositions.value
}
// Update self node position from deadman GPS data (contact_id = -1 for self)
function updateSelfPosition(lat: number, lng: number, label?: string) {
nodePositions.value.set(-1, {
lat,
lng,
label: label ?? 'This Node',
timestamp: new Date().toISOString(),
})
}
function markChatRead(contactId: number) {
viewingChatId.value = contactId
delete unreadCounts.value[contactId]
}
function clearViewingChat() {
viewingChatId.value = null
}
async function sendMessage(contactId: number, message: string) {
const doSend = async () => {
try {
sending.value = true
error.value = null
const res = await rpcClient.call<{ sent: boolean; message_id: number; encrypted: boolean }>({
method: 'mesh.send',
params: { contact_id: contactId, message: message.trim() },
})
// Refresh messages after sending
if (res.sent) {
await fetchMessages()
}
return res
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to send mesh message'
throw err
} finally {
sending.value = false
}
}
// Chain onto send queue to prevent concurrent fetchMessages() calls
const result = sendQueue.then(doSend, doSend)
sendQueue = result.then(() => {}, () => {})
return result
}
async function sendChannelMessage(channel: number, message: string) {
const doSend = async () => {
try {
sending.value = true
error.value = null
const res = await rpcClient.call<{ sent: boolean; message_id: number; channel: number }>({
method: 'mesh.send-channel',
params: { channel, message: message.trim() },
})
if (res.sent) {
await fetchMessages()
}
return res
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to send channel message'
throw err
} finally {
sending.value = false
}
}
const result = sendQueue.then(doSend, doSend)
sendQueue = result.then(() => {}, () => {})
return result
}
async function broadcastIdentity() {
try {
error.value = null
await rpcClient.call<{ broadcast: boolean }>({ method: 'mesh.broadcast' })
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to broadcast identity'
throw err
}
}
async function configure(config: Partial<MeshStatus>) {
try {
error.value = null
await rpcClient.call<{ configured: boolean }>({
method: 'mesh.configure',
params: config,
})
await fetchStatus()
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to configure mesh'
throw err
}
}
async function sendInvoice(contactId: number, amountSats: number, memo?: string) {
try {
sending.value = true
error.value = null
return await rpcClient.call<{ sent: boolean; amount_sats: number; bolt11: string }>({
method: 'mesh.send-invoice',
params: { contact_id: contactId, amount_sats: amountSats, memo },
})
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to send invoice'
throw err
} finally {
sending.value = false
}
}
async function sendCoordinate(contactId: number, lat: number, lng: number, label?: string) {
try {
sending.value = true
error.value = null
return await rpcClient.call<{ sent: boolean }>({
method: 'mesh.send-coordinate',
params: { contact_id: contactId, lat, lng, label },
})
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to send coordinate'
throw err
} finally {
sending.value = false
}
}
async function sendAlert(message: string, alertType: string, broadcast = false, lat?: number, lng?: number) {
try {
error.value = null
return await rpcClient.call<{ sent: boolean; signed: boolean }>({
method: 'mesh.send-alert',
params: { message, alert_type: alertType, broadcast, lat, lng },
})
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to send alert'
throw err
}
}
async function sendContent(contactId: number, cid: string, caption?: string, peerOnion?: string) {
try {
sending.value = true
error.value = null
const res = await rpcClient.call<{ sent: boolean; message_id: number; cid: string; size: number }>({
method: 'mesh.send-content',
params: { contact_id: contactId, cid, caption, peer_onion: peerOnion },
})
if (res.sent) await fetchMessages()
return res
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to send content'
throw err
} finally {
sending.value = false
}
}
async function sendReply(contactId: number, targetPubkey: string, targetSeq: number, text: string) {
sending.value = true
try {
const res = await rpcClient.call<{ sent: boolean; message_id: number; sender_seq: number }>({
method: 'mesh.send-reply',
params: { contact_id: contactId, target_pubkey: targetPubkey, target_seq: targetSeq, text },
})
if (res.sent) await fetchMessages()
return res
} finally {
sending.value = false
}
}
async function sendReaction(contactId: number, targetPubkey: string, targetSeq: number, emoji: string) {
try {
const res = await rpcClient.call<{ sent: boolean; message_id: number; sender_seq: number }>({
method: 'mesh.send-reaction',
params: { contact_id: contactId, target_pubkey: targetPubkey, target_seq: targetSeq, emoji },
})
if (res.sent) await fetchMessages()
return res
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to send reaction'
throw err
}
}
async function getOutbox() {
try {
return await rpcClient.call<{ count: number; messages?: unknown[] }>({ method: 'mesh.outbox' })
} catch {
return { count: 0 }
}
}
async function sendReadReceipt(contactId: number, targetPubkey: string, targetSeq: number) {
try {
const res = await rpcClient.call<{ sent: boolean }>({
method: 'mesh.send-read-receipt',
params: { contact_id: contactId, target_pubkey: targetPubkey, target_seq: targetSeq },
})
return res
} catch {
// Read receipts are best-effort — never surface errors to the user.
return null
}
}
async function forwardMessage(contactId: number, sourceMessageId: number) {
sending.value = true
try {
const res = await rpcClient.call<{ sent: boolean; message_id: number }>({
method: 'mesh.forward-message',
params: { contact_id: contactId, source_message_id: sourceMessageId },
})
if (res.sent) await fetchMessages()
return res
} finally {
sending.value = false
}
}
async function editMessage(contactId: number, targetSeq: number, newText: string) {
try {
const res = await rpcClient.call<{ sent: boolean; message_id: number }>({
method: 'mesh.edit-message',
params: { contact_id: contactId, target_seq: targetSeq, new_text: newText },
})
if (res.sent) await fetchMessages()
return res
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to edit message'
throw err
}
}
async function deleteMessage(contactId: number, targetSeq: number) {
try {
const res = await rpcClient.call<{ sent: boolean; message_id: number }>({
method: 'mesh.delete-message',
params: { contact_id: contactId, target_seq: targetSeq },
})
if (res.sent) await fetchMessages()
return res
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to delete message'
throw err
}
}
async function fetchContent(params: {
cid: string
sender_onion: string
cap_token: string
cap_exp: number
mime?: string
filename?: string
}) {
return rpcClient.call<{ fetched: boolean; cached: boolean; cid: string; size?: number; mime?: string }>({
method: 'mesh.fetch-content',
params,
})
}
async function getSessionStatus(contactId: number): Promise<SessionStatus> {
return rpcClient.call<SessionStatus>({
method: 'mesh.session-status',
params: { contact_id: contactId },
})
}
async function rotatePrekeys() {
return rpcClient.call<{ rotated: boolean; one_time_prekeys: number }>({
method: 'mesh.rotate-prekeys',
})
}
// ─── Phase 4: Off-Grid Bitcoin Operations ────────────────────────────
const deadmanStatus = ref<AlertStatus | null>(null)
const blockHeaders = ref<BlockHeader[]>([])
const latestBlockHeight = ref(0)
async function fetchDeadmanStatus() {
try {
deadmanStatus.value = await rpcClient.call<AlertStatus>({ method: 'mesh.deadman-status' })
} catch {
// Dead man switch not available
}
}
async function configureDeadman(config: {
enabled?: boolean
interval_secs?: number
lat?: number
lng?: number
label?: string
contacts?: string[]
custom_message?: string
auto_gps?: boolean
}) {
return rpcClient.call<AlertStatus>({
method: 'mesh.deadman-configure',
params: config,
})
}
async function deadmanCheckin() {
return rpcClient.call<{ checked_in: boolean; time_remaining_secs: number }>({
method: 'mesh.deadman-checkin',
})
}
async function fetchBlockHeaders(count = 10) {
try {
const res = await rpcClient.call<{ headers: BlockHeader[]; latest_height: number; count: number }>({
method: 'mesh.block-headers',
params: { count },
})
blockHeaders.value = res.headers
latestBlockHeight.value = res.latest_height
} catch {
// Block headers not available
}
}
async function relayTransaction(txHex: string, mode: 'archy' | 'broadcast' = 'archy') {
return rpcClient.call<{ request_id: number; queued: boolean; tx_hex_len: number }>({
method: 'mesh.relay-tx',
params: { tx_hex: txHex, relay_mode: mode },
})
}
async function relayLightning(bolt11: string, amountSats: number) {
return rpcClient.call<{ request_id: number; queued: boolean; amount_sats: number }>({
method: 'mesh.relay-lightning',
params: { bolt11, amount_sats: amountSats },
})
}
async function relayStatus(requestId: number) {
return rpcClient.call<{
status: 'pending' | 'confirmed' | 'failed' | 'unknown'
request_id: number
txid?: string
error?: string
error_code?: string
completed_at?: string
}>({
method: 'mesh.relay-status',
params: { request_id: requestId },
})
}
async function refreshAll() {
await Promise.all([fetchStatus(), fetchPeers(), fetchMessages(), fetchDeadmanStatus(), fetchBlockHeaders()])
}
return {
status,
peers,
messages,
loading,
error,
sending,
unreadCounts,
totalUnread,
nodePositions,
deadmanStatus,
blockHeaders,
latestBlockHeight,
fetchStatus,
fetchPeers,
fetchMessages,
sendMessage,
sendChannelMessage,
broadcastIdentity,
configure,
refreshAll,
markChatRead,
clearViewingChat,
sendInvoice,
sendCoordinate,
sendAlert,
sendContent,
fetchContent,
sendReply,
sendReaction,
getOutbox,
sendReadReceipt,
forwardMessage,
editMessage,
deleteMessage,
getSessionStatus,
rotatePrekeys,
getNodePositions,
updateSelfPosition,
fetchDeadmanStatus,
configureDeadman,
deadmanCheckin,
fetchBlockHeaders,
relayTransaction,
relayLightning,
relayStatus,
}
})