Some checks failed
Demo images / Build & push demo images (push) Has been cancelled
Device panel gets MeshCore RF fields (frequency MHz, bandwidth kHz, SF, CR) wired to mesh.configure's validated lora_radio_params; shown only for MeshCore radios, all-empty leaves the radio's flashed settings untouched. Human units in the UI, firmware field units on the wire. Per operator direction the panel's static stat rows duplicating editable fields (name, region, channel) are dropped — the editable control is the display. Stale 'adjust via a MeshCore client' hints updated: the node programs the radio now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
961 lines
30 KiB
TypeScript
961 lines
30 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[]
|
||
/** A serial radio is physically present, whether or not a session opened it. */
|
||
device_present?: boolean
|
||
/** Bitcoin block-header send/receive prefs (issue #28). */
|
||
announce_block_headers?: boolean
|
||
receive_block_headers?: boolean
|
||
/** Operator-configured LoRa region (e.g. "EU_868"), for the Device tab. */
|
||
region?: string | null
|
||
/** Persisted config: LoRa region the driver programs on fresh radios. */
|
||
lora_region?: string | null
|
||
/** Persisted config: firmware pin (meshcore|meshtastic|reticulum), null = auto-detect. */
|
||
device_kind?: string | null
|
||
/** USB identity per detected port (for the setup modal's board image). */
|
||
detected_device_info?: Array<{
|
||
path: string
|
||
vid?: string | null
|
||
pid?: string | null
|
||
product?: string | null
|
||
manufacturer?: string | null
|
||
}>
|
||
}
|
||
|
||
/** Params accepted by mesh.configure (superset of the status fields). */
|
||
export interface MeshConfigureParams {
|
||
enabled?: boolean
|
||
device_path?: string
|
||
channel_name?: string
|
||
broadcast_identity?: boolean
|
||
advert_name?: string
|
||
announce_block_headers?: boolean
|
||
receive_block_headers?: boolean
|
||
lora_region?: string
|
||
device_kind?: string
|
||
/** MeshCore LoRa PHY params in firmware field units (freq_khz = MHz×1000,
|
||
* bw_hz = kHz×1000). null clears; omitted leaves unchanged. */
|
||
lora_radio_params?: { freq_khz: number; bw_hz: number; sf: number; cr: number } | null
|
||
}
|
||
|
||
export interface MeshPeer {
|
||
contact_id: number
|
||
advert_name: string
|
||
did: string | null
|
||
pubkey_hex: string | null
|
||
/** Verified archipelago ed25519 identity key. The backend binds this onto
|
||
* BOTH a node's twins — the federation peer natively and the radio twin via
|
||
* `bind_federation_twins` — so it's the most reliable key for collapsing the
|
||
* cross-transport duplicate (survives the device rename, unlike the advert
|
||
* name). Absent on radio peers that were never matched to a federation twin. */
|
||
arch_pubkey_hex?: string | null
|
||
rssi: number | null
|
||
snr: number | null
|
||
last_heard: string
|
||
hops: number
|
||
last_advert?: number
|
||
reachable?: boolean
|
||
/** Last known position (degrees), from a Meshtastic POSITION_APP broadcast.
|
||
* Absent until the peer has shared one. */
|
||
lat?: number | null
|
||
lon?: number | null
|
||
}
|
||
|
||
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
|
||
/// How the message traveled: "meshtastic", "meshcore", "reticulum" (radio
|
||
/// transports, one per device kind), "fips", or "tor". Drives the
|
||
/// per-message transport pill. Absent until known.
|
||
transport?: string | null
|
||
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 DeniedAsker {
|
||
contact_id: number
|
||
name: string
|
||
pubkey_hex: string | null
|
||
at: string
|
||
}
|
||
|
||
export interface AssistantStatus {
|
||
enabled: boolean
|
||
model: string | null
|
||
trusted_only: boolean
|
||
backend: string
|
||
allowed_contacts: string[]
|
||
default_model: string
|
||
ollama_detected: boolean
|
||
claude_available: boolean
|
||
models: string[]
|
||
denied_askers?: DeniedAsker[]
|
||
}
|
||
|
||
export interface ScheduledMessage {
|
||
id: number
|
||
contact_id: number | null
|
||
channel: number | null
|
||
body: string
|
||
fire_at: number
|
||
attempts: number
|
||
}
|
||
|
||
export interface NodePosition {
|
||
lat: number
|
||
lng: number
|
||
label?: string
|
||
timestamp: string
|
||
}
|
||
|
||
/// An Archipelago node (federation peer), not a raw LoRa radio peer — shown
|
||
/// on the Mesh Map with its own marker (the Archy logo) since it's a whole
|
||
/// server, not a mesh-radio contact. Only ones that opted into
|
||
/// server.set-location's `share` flag ever carry a lat/lon.
|
||
export interface FederatedNodePosition {
|
||
did: string
|
||
lat: number
|
||
lng: number
|
||
name: string | null
|
||
onion: string
|
||
trusted: boolean
|
||
}
|
||
|
||
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())
|
||
// Federated Archipelago nodes with a shared location (DID -> position) —
|
||
// separate from nodePositions since these are whole servers, not radio
|
||
// peers, and render with a different marker on the Mesh Map.
|
||
const federatedPositions = ref<Map<string, FederatedNodePosition>>(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
|
||
}
|
||
}
|
||
|
||
// ── Global device-detection (for the app-wide "device detected" modal) ──
|
||
// Ports the user dismissed the setup modal for, persisted per browser.
|
||
const DETECT_DISMISS_KEY = 'archipelago.mesh.detect-dismissed.v1'
|
||
const dismissedDetectedPaths = ref<Set<string>>(new Set(
|
||
JSON.parse(localStorage.getItem(DETECT_DISMISS_KEY) || '[]') as string[]
|
||
))
|
||
const undismissedDetectedDevices = computed(() => {
|
||
const s = status.value
|
||
if (!s || s.device_connected || s.enabled) return []
|
||
return (s.detected_devices || []).filter(p => !dismissedDetectedPaths.value.has(p))
|
||
})
|
||
function dismissDetectedDevice(path: string) {
|
||
dismissedDetectedPaths.value.add(path)
|
||
dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value)
|
||
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value]))
|
||
}
|
||
let globalDetectTimer: ReturnType<typeof setInterval> | null = null
|
||
/** App-wide light poll so the detected-device modal works on every page
|
||
* (the Mesh view's own 5s poll takes over while it is mounted). */
|
||
function startGlobalDetection(intervalMs = 15000) {
|
||
if (globalDetectTimer) return
|
||
// Never poll unauthenticated — a 401 from a background poll on the
|
||
// login page caused a login-refresh loop (2026-07-14, .116).
|
||
const authed = () => {
|
||
try { return !!localStorage.getItem('neode-auth') } catch { return false }
|
||
}
|
||
if (authed()) void fetchStatus()
|
||
globalDetectTimer = setInterval(() => {
|
||
if (document.visibilityState === 'visible' && authed()) void fetchStatus()
|
||
}, intervalMs)
|
||
}
|
||
|
||
async function fetchPeers() {
|
||
try {
|
||
const res = await rpcClient.call<{ peers: MeshPeer[]; count: number }>({
|
||
method: 'mesh.peers',
|
||
})
|
||
peers.value = res.peers
|
||
updateNodePositionsFromPeers(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'
|
||
}
|
||
}
|
||
|
||
async function refreshMessagesAfterSend() {
|
||
await fetchMessages()
|
||
// Federation sends stamp FIPS/Tor asynchronously after the RPC returns.
|
||
// Poll briefly so the transport pill settles without a browser refresh.
|
||
for (const delay of [350, 900, 1600]) {
|
||
setTimeout(() => { void fetchMessages() }, delay)
|
||
}
|
||
}
|
||
|
||
// 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,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Feed real Meshtastic POSITION_APP broadcasts into the same map the
|
||
* manually-shared Coordinate/Alert messages already populate — MeshMap.vue
|
||
* needs no changes, it already renders from `nodePositions`. Uses
|
||
* `last_heard` as the freshness timestamp, same comparison pattern as
|
||
* `updateNodePositionsFromMessages`, so a peer position never clobbers a
|
||
* more-recently-shared coordinate message and vice versa. */
|
||
function updateNodePositionsFromPeers(peerList: MeshPeer[]) {
|
||
for (const peer of peerList) {
|
||
if (typeof peer.lat !== 'number' || typeof peer.lon !== 'number') continue
|
||
const existing = nodePositions.value.get(peer.contact_id)
|
||
if (!existing || peer.last_heard > existing.timestamp) {
|
||
nodePositions.value.set(peer.contact_id, {
|
||
lat: peer.lat,
|
||
lng: peer.lon,
|
||
label: peer.advert_name,
|
||
timestamp: peer.last_heard,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
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(),
|
||
})
|
||
}
|
||
|
||
// Federation nodes that opted into sharing their location (server.set-location
|
||
// `share: true`) — fed by Mesh.vue's existing federation.list-nodes poll.
|
||
function updateFederatedPositions(nodes: Array<{
|
||
did: string
|
||
name?: string | null
|
||
onion: string
|
||
trust_level: string
|
||
last_state?: { lat?: number | null; lon?: number | null } | null
|
||
}>) {
|
||
const next = new Map<string, FederatedNodePosition>()
|
||
for (const n of nodes) {
|
||
const lat = n.last_state?.lat
|
||
const lon = n.last_state?.lon
|
||
if (typeof lat === 'number' && typeof lon === 'number') {
|
||
next.set(n.did, {
|
||
did: n.did,
|
||
lat,
|
||
lng: lon,
|
||
name: n.name ?? null,
|
||
onion: n.onion,
|
||
trusted: n.trust_level === 'trusted',
|
||
})
|
||
}
|
||
}
|
||
federatedPositions.value = next
|
||
}
|
||
|
||
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 refreshMessagesAfterSend()
|
||
}
|
||
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 refreshMessagesAfterSend()
|
||
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: MeshConfigureParams) {
|
||
try {
|
||
error.value = null
|
||
await rpcClient.call<{ configured: boolean }>({
|
||
method: 'mesh.configure',
|
||
params: config as Record<string, unknown>,
|
||
})
|
||
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 refreshMessagesAfterSend()
|
||
return res
|
||
} catch (err: unknown) {
|
||
error.value = err instanceof Error ? err.message : 'Failed to send content'
|
||
throw err
|
||
} finally {
|
||
sending.value = false
|
||
}
|
||
}
|
||
|
||
async function transportAdvice(contactId: number, size: number) {
|
||
return rpcClient.call<{
|
||
tier: 'auto-mesh' | 'choose' | 'resource-mesh' | 'tor-only' | 'impossible'
|
||
est_seconds: number
|
||
has_tor: boolean
|
||
reason: string
|
||
size: number
|
||
mesh_auto_max: number
|
||
mesh_hard_max: number
|
||
}>({
|
||
method: 'mesh.transport-advice',
|
||
params: { contact_id: contactId, size },
|
||
})
|
||
}
|
||
|
||
async function sendContentInline(
|
||
contactId: number,
|
||
mime: string,
|
||
bytes: Uint8Array,
|
||
filename?: string,
|
||
caption?: string,
|
||
) {
|
||
try {
|
||
sending.value = true
|
||
error.value = null
|
||
// Base64-encode bytes for JSON transport.
|
||
let binary = ''
|
||
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]!)
|
||
const bytes_b64 = btoa(binary)
|
||
const res = await rpcClient.call<{ sent: boolean; message_id: number; cid: string; size: number }>({
|
||
method: 'mesh.send-content-inline',
|
||
params: { contact_id: contactId, mime, filename, caption, bytes_b64 },
|
||
})
|
||
if (res.sent) await refreshMessagesAfterSend()
|
||
return res
|
||
} catch (err: unknown) {
|
||
error.value = err instanceof Error ? err.message : 'Failed to send inline 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 refreshMessagesAfterSend()
|
||
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 refreshMessagesAfterSend()
|
||
return res
|
||
} catch (err: unknown) {
|
||
error.value = err instanceof Error ? err.message : 'Failed to send reaction'
|
||
throw err
|
||
}
|
||
}
|
||
|
||
async function rebootRadio(seconds = 2) {
|
||
return rpcClient.call<{ reboot: boolean; seconds: number }>({
|
||
method: 'mesh.reboot-radio',
|
||
params: { seconds },
|
||
})
|
||
}
|
||
|
||
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 refreshMessagesAfterSend()
|
||
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 refreshMessagesAfterSend()
|
||
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 refreshMessagesAfterSend()
|
||
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)
|
||
|
||
// Mesh-AI assistant (issue #50)
|
||
const assistantStatus = ref<AssistantStatus | null>(null)
|
||
|
||
async function fetchAssistantStatus() {
|
||
try {
|
||
assistantStatus.value = await rpcClient.call<AssistantStatus>({ method: 'mesh.assistant-status' })
|
||
} catch {
|
||
// Assistant not available (mesh service down)
|
||
}
|
||
}
|
||
|
||
async function configureAssistant(config: {
|
||
enabled?: boolean
|
||
model?: string | null
|
||
trusted_only?: boolean
|
||
backend?: string
|
||
allowed_contacts?: string[]
|
||
}) {
|
||
const res = await rpcClient.call<Partial<AssistantStatus>>({
|
||
method: 'mesh.assistant-configure',
|
||
params: config,
|
||
})
|
||
await fetchAssistantStatus()
|
||
return res
|
||
}
|
||
|
||
// Scheduled / queued mesh messages (issue #50, phase 1.7)
|
||
const scheduledMessages = ref<ScheduledMessage[]>([])
|
||
|
||
async function fetchScheduledMessages() {
|
||
try {
|
||
const res = await rpcClient.call<{ messages: ScheduledMessage[] }>({ method: 'mesh.list-scheduled' })
|
||
scheduledMessages.value = res.messages || []
|
||
} catch {
|
||
scheduledMessages.value = []
|
||
}
|
||
}
|
||
|
||
async function scheduleMessage(params: {
|
||
contact_id?: number
|
||
channel?: number
|
||
body: string
|
||
fire_at: number
|
||
}) {
|
||
const res = await rpcClient.call<ScheduledMessage>({ method: 'mesh.schedule-message', params })
|
||
await fetchScheduledMessages()
|
||
return res
|
||
}
|
||
|
||
async function cancelScheduledMessage(id: number) {
|
||
await rpcClient.call({ method: 'mesh.cancel-scheduled', params: { id } })
|
||
await fetchScheduledMessages()
|
||
}
|
||
|
||
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,
|
||
federatedPositions,
|
||
updateFederatedPositions,
|
||
deadmanStatus,
|
||
blockHeaders,
|
||
latestBlockHeight,
|
||
fetchStatus,
|
||
undismissedDetectedDevices,
|
||
dismissDetectedDevice,
|
||
startGlobalDetection,
|
||
fetchPeers,
|
||
fetchMessages,
|
||
sendMessage,
|
||
sendChannelMessage,
|
||
broadcastIdentity,
|
||
configure,
|
||
refreshAll,
|
||
markChatRead,
|
||
clearViewingChat,
|
||
sendInvoice,
|
||
sendCoordinate,
|
||
sendAlert,
|
||
sendContent,
|
||
sendContentInline,
|
||
transportAdvice,
|
||
fetchContent,
|
||
sendReply,
|
||
sendReaction,
|
||
rebootRadio,
|
||
getOutbox,
|
||
sendReadReceipt,
|
||
forwardMessage,
|
||
editMessage,
|
||
deleteMessage,
|
||
getSessionStatus,
|
||
rotatePrekeys,
|
||
getNodePositions,
|
||
updateSelfPosition,
|
||
fetchDeadmanStatus,
|
||
configureDeadman,
|
||
deadmanCheckin,
|
||
assistantStatus,
|
||
fetchAssistantStatus,
|
||
configureAssistant,
|
||
scheduledMessages,
|
||
fetchScheduledMessages,
|
||
scheduleMessage,
|
||
cancelScheduledMessage,
|
||
fetchBlockHeaders,
|
||
relayTransaction,
|
||
relayLightning,
|
||
relayStatus,
|
||
}
|
||
})
|