detected_device_info now carries plugged_at (the /dev node's creation time; udev recreates it per plug). Dismissals store (path -> plugged_at), so swapping sticks or a fast unplug/replug between status polls can no longer be suppressed by an old 'Not Now' (v1 path-only dismissals are abandoned wholesale). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1042 lines
33 KiB
TypeScript
1042 lines
33 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
|
||
/** Epoch seconds the /dev node appeared — changes on every replug. */
|
||
plugged_at?: number | null
|
||
}>
|
||
/** Hot-swap "keep as is": false = archipelago never writes config to the radio. */
|
||
manage_radio?: boolean
|
||
/** Persisted config: MeshCore LoRa PHY params (firmware units). */
|
||
lora_radio_params?: { freq_khz: number; bw_hz: number; sf: number; cr: number } | null
|
||
}
|
||
|
||
/** What mesh.probe-device read off a just-plugged radio (no provisioning). */
|
||
export interface MeshDeviceProbe {
|
||
path: string
|
||
kind: 'reticulum' | 'meshcore' | 'meshtastic'
|
||
firmware_version: string | null
|
||
node_id: number | null
|
||
advert_name: string | null
|
||
region: string | null
|
||
modem_preset: string | null
|
||
primary_channel: string | null
|
||
secondary_channel: string | null
|
||
max_contacts: number | 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
|
||
/** Hot-swap "keep as is": false = use the radio exactly as flashed. */
|
||
manage_radio?: boolean
|
||
}
|
||
|
||
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
|
||
trackDetectedDevices(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.
|
||
// Dismissals are cleared the moment the port disappears (unplug), so the
|
||
// modal re-appears on EVERY plug-in — including swapping a different stick
|
||
// into the same /dev path — per the hot-swap UX (2026-07-22).
|
||
// v2: dismissals key on (path → plugged_at). udev recreates the /dev node
|
||
// on every plug, so plugged_at changes on each replug — an old "Not Now"
|
||
// can never suppress a NEWLY plugged stick, even when the swap happens
|
||
// faster than a status poll (which absence-pruning alone missed) or when
|
||
// the same /dev path is reused. v1 (a bare path set) is intentionally
|
||
// abandoned: stale one-time dismissals from before 2026-07-22 shouldn't
|
||
// suppress anything either.
|
||
const DETECT_DISMISS_KEY = 'archipelago.mesh.detect-dismissed.v2'
|
||
const dismissedDetected = ref<Record<string, number>>(
|
||
JSON.parse(localStorage.getItem(DETECT_DISMISS_KEY) || '{}') as Record<string, number>
|
||
)
|
||
function pluggedAt(s: MeshStatus, path: string): number {
|
||
return s.detected_device_info?.find(d => d.path === path)?.plugged_at ?? 0
|
||
}
|
||
// Consecutive polls each candidate port has been present-but-not-connected.
|
||
// The modal waits for 2 sightings so it doesn't flash during the couple of
|
||
// seconds an ordinary reconnect (same radio, transient blip) needs.
|
||
const detectSightings = ref<Record<string, number>>({})
|
||
const undismissedDetectedDevices = computed(() => {
|
||
const s = status.value
|
||
if (!s) return []
|
||
return (s.detected_devices || []).filter(p =>
|
||
dismissedDetected.value[p] !== pluggedAt(s, p) &&
|
||
// The port the live session occupies is not a candidate…
|
||
!(s.device_connected && s.device_path === p) &&
|
||
// …and a port only qualifies once it has survived the blip debounce.
|
||
(detectSightings.value[p] ?? 0) >= 2
|
||
)
|
||
})
|
||
/** Called after every status fetch: advance sighting counters and drop
|
||
* dismissals for ports that vanished (belt-and-braces with plugged_at). */
|
||
function trackDetectedDevices(s: MeshStatus) {
|
||
const present = new Set(s.detected_devices || [])
|
||
const next: Record<string, number> = {}
|
||
for (const p of present) {
|
||
next[p] = s.device_connected && s.device_path === p
|
||
? 0
|
||
: (detectSightings.value[p] ?? 0) + 1
|
||
}
|
||
detectSightings.value = next
|
||
let dirty = false
|
||
for (const p of Object.keys(dismissedDetected.value)) {
|
||
if (!present.has(p)) {
|
||
delete dismissedDetected.value[p]
|
||
dirty = true
|
||
}
|
||
}
|
||
if (dirty) {
|
||
dismissedDetected.value = { ...dismissedDetected.value }
|
||
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value))
|
||
}
|
||
}
|
||
function dismissDetectedDevice(path: string) {
|
||
const s = status.value
|
||
dismissedDetected.value = {
|
||
...dismissedDetected.value,
|
||
[path]: s ? pluggedAt(s, path) : 0,
|
||
}
|
||
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value))
|
||
}
|
||
/** Read-only firmware/config probe of a detected port (hot-swap modal). */
|
||
async function probeDevice(path: string): Promise<MeshDeviceProbe> {
|
||
return rpcClient.call<MeshDeviceProbe>({
|
||
method: 'mesh.probe-device',
|
||
params: { path },
|
||
timeout: 45000, // serial probes are slow (multi-firmware handshakes)
|
||
})
|
||
}
|
||
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,
|
||
probeDevice,
|
||
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,
|
||
}
|
||
})
|