fix(mesh): Reticulum garbage-text + reconnect churn + signal bars + node naming/HTTPS

- reticulum.rs: send_text_msg was lossy-UTF8-mangling binary CBOR control
  envelopes (ReadReceipt etc.) before sending as LXMF text; base64-encode
  with a marker instead, decoded losslessly on receive.
- typed_messages.rs: mesh.send-read-receipt fired automatically on every
  chat view with no is_archy_peer gate, so viewing a message from a stock
  (non-archy) LXMF peer auto-sent it an undecodable control envelope,
  surfacing as garbage text right after whatever it just sent. Now a no-op
  for non-archy peers.
- mesh/listener/mod.rs: RX_STALL_TIMEOUT was 300s and forced a full
  auto-detect reconnect on any otherwise-healthy but quiet mesh link
  (visible as "Connecting..." flapping); this also wiped Reticulum's
  in-memory peer-address table every cycle, breaking messaging with peers
  who hadn't re-announced in the window. Bumped to 1800s.
- reticulum.rs: persist the peer prefix/dest-hash/display-name table to
  disk so a restart doesn't force every peer back to "Anonymous Peer"
  until they re-announce.
- decode.rs/frames.rs: Meshcore was discarding the SNR its wire format
  carries; wire it onto the peer record. Mesh.vue's signalBars() now falls
  back to SNR-based bars when RSSI is unavailable (always true for
  Meshcore); Reticulum has neither and correctly stays at 0/"no data".
- system/handlers.rs, dispatcher.rs: new system.get-hostname RPC + cert
  regeneration (with a proper SAN) whenever server.set-name changes the
  hostname, so HTTPS doesn't add a mismatch warning on top of the
  self-signed one after a rename.
- AccountInfoSection.vue: surface the mDNS hostname + http/https links in
  Settings (HTTPS needed for mic/camera secure-context features) — never
  forced, both keep working.
- build-auto-installer-iso.sh: ship avahi-daemon so .local names actually
  resolve on the LAN, and give the self-signed cert a real SAN instead of
  a bare CN, both at image-build and install-time-fallback.
- Mesh.vue/MediaLightbox.vue/mesh-styles.css: mic/attach-stack no longer
  closes on a plain hover-past; mesh images open in the shared lightbox
  and have a real download button; lightbox close button moves to
  bottom-center on mobile instead of under the status bar; mesh device
  panel gets the same height/padding as its sibling tabs.

Verified: 108/108 mesh unit tests, deployed + confirmed healthy on
.116/.198/.228 (matching binary hash across all three), live Reticulum
messaging confirmed working end-to-end post-deploy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-01 10:42:20 -04:00
co-authored by Claude Sonnet 5
parent 99cd82ab0a
commit bebf3bae10
12 changed files with 486 additions and 37 deletions
+113 -17
View File
@@ -13,6 +13,8 @@ import MeshAssistantPanel from '@/views/mesh/MeshAssistantPanel.vue'
import { rpcClient } from '@/api/rpc-client'
import { wsClient } from '@/api/websocket'
import { IMAGE_COMPRESSION_PRESETS, compressImage, makeThumbnail, type ImageCompressionPreset } from '@/utils/imageCompression'
import MediaLightbox from '@/components/cloud/MediaLightbox.vue'
import type { FileBrowserItem } from '@/api/filebrowser-client'
import '@/views/mesh/mesh-styles.css'
const mesh = useMeshStore()
@@ -1049,12 +1051,24 @@ watch(
{ immediate: true },
)
function signalBars(rssi: number | null): number {
if (rssi === null) return 0
if (rssi > -60) return 4
if (rssi > -75) return 3
if (rssi > -90) return 2
return 1
function signalBars(rssi: number | null, snr: number | null = null): number {
if (rssi !== null) {
if (rssi > -60) return 4
if (rssi > -75) return 3
if (rssi > -90) return 2
return 1
}
// Meshcore carries no per-packet RSSI, only SNR — fall back to it so the
// bars aren't permanently empty for that transport (Reticulum has neither,
// stays at 0/"No signal data yet", which is honest — RNS/LXMF genuinely
// doesn't expose a signal-quality metric).
if (snr !== null) {
if (snr > 5) return 4
if (snr > 0) return 3
if (snr > -10) return 2
return 1
}
return 0
}
function signalTitle(rssi: number | null, snr: number | null): string {
@@ -1661,6 +1675,68 @@ function isImageMime(mime?: string): boolean {
return !!mime && mime.startsWith('image/')
}
const IMAGE_MIME_TO_EXT: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/webp': 'webp',
'image/bmp': 'bmp',
'image/svg+xml': 'svg',
}
// Lightbox is FileBrowserItem-shaped (built for the cloud file browser) — a
// mesh attachment isn't a real file-browser entry, so we key it by `cid`
// and hand back the already-fetched blob URL instead of a real fetch.
const lightboxOpen = ref(false)
const lightboxItems = ref<FileBrowserItem[]>([])
type MeshAttachmentPayload = {
cid: string
sender_onion: string
cap_token: string
cap_exp: number
mime?: string
filename?: string | null
}
async function openMeshLightbox(payload: MeshAttachmentPayload) {
if (!fetchedUrls.value.has(payload.cid)) {
await handleFetchContent(payload)
}
if (!fetchedUrls.value.has(payload.cid)) return // fetch failed
const ext = IMAGE_MIME_TO_EXT[payload.mime || ''] || 'jpg'
lightboxItems.value = [
{
name: payload.filename || `image.${ext}`,
path: payload.cid,
size: 0,
modified: '',
isDir: false,
type: payload.mime || 'image/jpeg',
extension: ext,
},
]
lightboxOpen.value = true
}
async function lightboxFetchBlobUrl(cid: string): Promise<string> {
return fetchedUrls.value.get(cid) || ''
}
async function downloadAttachment(payload: MeshAttachmentPayload) {
if (!fetchedUrls.value.has(payload.cid)) {
await handleFetchContent(payload)
}
const url = fetchedUrls.value.get(payload.cid)
if (!url) return
const a = document.createElement('a')
a.href = url
a.download = payload.filename || `image.${IMAGE_MIME_TO_EXT[payload.mime || ''] || 'jpg'}`
document.body.appendChild(a)
a.click()
a.remove()
}
</script>
<template>
@@ -1873,7 +1949,7 @@ function isImageMime(mime?: string): boolean {
</span>
<div class="mesh-peer-signal" :title="signalTitle(mp.primary_rssi, mp.primary_snr)">
<div class="mesh-signal-bars">
<div v-for="i in 4" :key="i" class="mesh-signal-bar" :class="{ active: i <= signalBars(mp.primary_rssi) }" />
<div v-for="i in 4" :key="i" class="mesh-signal-bar" :class="{ active: i <= signalBars(mp.primary_rssi, mp.primary_snr) }" />
</div>
</div>
</div>
@@ -1898,7 +1974,7 @@ function isImageMime(mime?: string): boolean {
Map
</button>
<button class="mesh-tab" :class="{ active: activeTab === 'device' }" @click="activeTab = 'device'" title="Device settings">
📡
</button>
</div>
@@ -2060,12 +2136,21 @@ function isImageMime(mime?: string): boolean {
</div>
<div v-if="msg.typed_payload.caption" class="mesh-typed-content-caption">{{ msg.typed_payload.caption }}</div>
<template v-if="fetchedUrls.get(msg.typed_payload.cid)">
<img
v-if="isImageMime(msg.typed_payload.mime)"
:src="fetchedUrls.get(msg.typed_payload.cid)"
class="mesh-typed-content-preview"
alt="attachment"
/>
<div v-if="isImageMime(msg.typed_payload.mime)" class="mesh-typed-content-image-wrap">
<img
:src="fetchedUrls.get(msg.typed_payload.cid)"
class="mesh-typed-content-preview"
alt="attachment"
role="button"
title="Click to view full-size"
@click="openMeshLightbox(msg.typed_payload as any)"
/>
<button
class="mesh-typed-content-download-btn"
title="Download"
@click="downloadAttachment(msg.typed_payload as any)"
>&#x2B07;</button>
</div>
<audio
v-else-if="(msg.typed_payload.mime || '').startsWith('audio/')"
:src="fetchedUrls.get(msg.typed_payload.cid)"
@@ -2083,6 +2168,9 @@ function isImageMime(mime?: string): boolean {
:src="`data:${msg.typed_payload.mime};base64,${msg.typed_payload.thumb_bytes}`"
class="mesh-typed-content-preview mesh-typed-content-thumb"
alt="thumbnail preview"
role="button"
title="Click to view full-size"
@click="openMeshLightbox(msg.typed_payload as any)"
/>
<button
class="btn"
@@ -2194,7 +2282,7 @@ function isImageMime(mime?: string): boolean {
:title="isRecordingVoice ? 'Release to send' : 'Hold to record a voice message'"
@pointerdown.prevent="startVoiceRecording"
@pointerup.prevent="() => { stopVoiceRecording(); showAttachMenu = false }"
@pointerleave="() => { stopVoiceRecordingIfActive(); showAttachMenu = false }"
@pointerleave="() => { if (isRecordingVoice) { stopVoiceRecordingIfActive(); showAttachMenu = false } }"
>
<span v-if="isRecordingVoice" class="mesh-spinner" aria-hidden="true"></span>
<span v-else>🎤</span>
@@ -2243,7 +2331,7 @@ function isImageMime(mime?: string): boolean {
AI
</button>
<button class="mesh-tab" :class="{ active: toolsTab === 'map' }" @click="toolsTab = 'map'">Map</button>
<button class="mesh-tab" :class="{ active: toolsTab === 'device' }" @click="toolsTab = 'device'" title="Device settings"></button>
<button class="mesh-tab" :class="{ active: toolsTab === 'device' }" @click="toolsTab = 'device'" title="Device settings">📡</button>
</div>
<MeshBitcoinPanel v-if="showBitcoinPanel" />
<MeshDeadmanPanel v-if="showDeadmanPanel" />
@@ -2277,7 +2365,7 @@ function isImageMime(mime?: string): boolean {
</button>
<button class="mesh-mtab" :class="{ active: mobileTab === 'assistant' }" @click="selectMobileTab('assistant')">AI</button>
<button class="mesh-mtab" :class="{ active: mobileTab === 'map' }" @click="selectMobileTab('map')">Map</button>
<button class="mesh-mtab" :class="{ active: mobileTab === 'device' }" @click="selectMobileTab('device')" title="Device settings"></button>
<button class="mesh-mtab" :class="{ active: mobileTab === 'device' }" @click="selectMobileTab('device')" title="Device settings">📡</button>
</div>
</Teleport>
@@ -2364,6 +2452,14 @@ function isImageMime(mime?: string): boolean {
</div>
</div>
<MediaLightbox
:items="lightboxItems"
:start-index="0"
:show="lightboxOpen"
:fetch-blob-url="lightboxFetchBlobUrl"
@close="lightboxOpen = false"
/>
</div>
</template>