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:
co-authored by
Claude Sonnet 5
parent
99cd82ab0a
commit
bebf3bae10
+113
-17
@@ -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)"
|
||||
>⬇</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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user