diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 3e132fb8..a9bfea0a 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -421,6 +421,7 @@ impl RpcHandler { "server.set-name" => self.handle_server_set_name(params).await, // System monitoring + "system.get-hostname" => self.handle_system_get_hostname().await, "system.stats" => self.handle_system_stats().await, "system.processes" => self.handle_system_processes().await, "system.temperature" => self.handle_system_temperature().await, diff --git a/core/archipelago/src/api/rpc/mesh/typed_messages.rs b/core/archipelago/src/api/rpc/mesh/typed_messages.rs index 7ea1ba09..e7677ad5 100644 --- a/core/archipelago/src/api/rpc/mesh/typed_messages.rs +++ b/core/archipelago/src/api/rpc/mesh/typed_messages.rs @@ -933,6 +933,15 @@ impl RpcHandler { let svc = service .as_ref() .ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?; + // Read receipts are fired automatically just by viewing a chat (no + // explicit user action), unlike every other typed send here — so a + // stock (non-archy) peer that can't decode a TypedEnvelope at all + // (e.g. a phone running plain Sideband) would otherwise get a raw + // control envelope shoved at it the moment its message is viewed, + // surfacing as garbage text right after whatever it just sent. + if !svc.is_archy_peer(contact_id).await { + return Ok(serde_json::json!({ "sent": false, "reason": "not an archy peer" })); + } let seq = svc.next_send_seq(contact_id).await; let payload = message_types::encode_payload(&receipt)?; let envelope = TypedEnvelope::new(MeshMessageType::ReadReceipt, payload).with_seq(seq); diff --git a/core/archipelago/src/api/rpc/system/handlers.rs b/core/archipelago/src/api/rpc/system/handlers.rs index 573f4600..972a262e 100644 --- a/core/archipelago/src/api/rpc/system/handlers.rs +++ b/core/archipelago/src/api/rpc/system/handlers.rs @@ -47,6 +47,17 @@ impl RpcHandler { } }; + // Keep the self-signed HTTPS cert's SAN in sync with the new hostname — + // best-effort, never blocks the rename itself. Without this the cert + // stays pinned to whatever name was set at install time, so browsers + // hit a hostname-mismatch warning on top of the usual self-signed one + // the moment a node is renamed. + if hostname_updated { + if let Err(e) = regenerate_tls_cert(&hostname).await { + warn!(hostname = %hostname, "TLS cert regen after rename failed: {}", e); + } + } + info!("Server name updated to: {}", name); // Push the new name to federation peers in background @@ -66,6 +77,21 @@ impl RpcHandler { })) } + /// system.get-hostname — Current OS hostname + the mDNS `.local` name it + /// resolves to on the LAN (avahi-daemon advertises `.local`). + /// Lets Settings show users where to reach this node over HTTPS for + /// features (mic/camera access) that require a secure context. + pub(in crate::api::rpc) async fn handle_system_get_hostname(&self) -> Result { + let hostname = tokio::fs::read_to_string("/etc/hostname") + .await + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| "archipelago".to_string()); + Ok(serde_json::json!({ + "hostname": hostname, + "mdns_hostname": format!("{hostname}.local"), + })) + } + /// system.stats — CPU usage, RAM used/total, disk used/total, uptime, load average pub(in crate::api::rpc) async fn handle_system_stats(&self) -> Result { debug!("Getting system stats"); @@ -319,6 +345,63 @@ async fn set_system_hostname(hostname: &str) -> Result<()> { Ok(()) } +/// Regenerate the self-signed HTTPS cert (`/etc/archipelago/ssl/archipelago.{crt,key}`) +/// with a SAN covering `hostname`, `hostname.local`, `localhost`, and 127.0.0.1, then +/// reload nginx so it picks up the new cert. Still self-signed (browsers will warn +/// on first visit regardless), but avoids stacking a hostname-mismatch warning on +/// top once a node has been renamed away from the install-time default. +async fn regenerate_tls_cert(hostname: &str) -> Result<()> { + let subj = format!("/C=XX/ST=Bitcoin/L=Node/O=Archipelago/CN={hostname}"); + let san = format!("subjectAltName=DNS:{hostname},DNS:{hostname}.local,DNS:localhost,IP:127.0.0.1"); + let output = tokio::process::Command::new("/usr/bin/sudo") + .args([ + "-n", + "/usr/bin/openssl", + "req", + "-x509", + "-nodes", + "-days", + "3650", + "-newkey", + "rsa:2048", + "-keyout", + "/etc/archipelago/ssl/archipelago.key", + "-out", + "/etc/archipelago/ssl/archipelago.crt", + "-subj", + &subj, + "-addext", + &san, + ]) + .output() + .await + .context("Failed to run openssl")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + anyhow::bail!( + "{}", + if stderr.is_empty() { + "openssl cert regen failed".to_string() + } else { + stderr + } + ); + } + + let reload = tokio::process::Command::new("/usr/bin/sudo") + .args(["-n", "/usr/bin/systemctl", "reload", "nginx"]) + .output() + .await + .context("Failed to reload nginx")?; + if !reload.status.success() { + let stderr = String::from_utf8_lossy(&reload.stderr).trim().to_string(); + anyhow::bail!("nginx reload failed: {}", stderr); + } + + Ok(()) +} + impl RpcHandler { /// system.factory-reset — Wipe all user data, remove containers, and restart. /// Only preserves the data_dir itself (recreated empty on restart). diff --git a/core/archipelago/src/mesh/listener/decode.rs b/core/archipelago/src/mesh/listener/decode.rs index ddd3b47f..3eb764cf 100644 --- a/core/archipelago/src/mesh/listener/decode.rs +++ b/core/archipelago/src/mesh/listener/decode.rs @@ -381,6 +381,17 @@ fn meshtastic_peer_from_prefix(sender_prefix: &str) -> Option<(u32, String, Stri Some((node_num, hex::encode(full_key), name)) } +/// Stamp the SNR carried in a Meshcore v3 contact-message frame onto the +/// sender's peer record so the signal-bars indicator has real data (Meshcore +/// has no per-packet RSSI like Meshtastic, only this 1-byte SNR — see +/// `protocol::parse_contact_msg_v3_raw`). +pub(super) async fn update_peer_snr(state: &Arc, contact_id: u32, snr: f32) { + let mut peers = state.peers.write().await; + if let Some(peer) = peers.get_mut(&contact_id) { + peer.snr = Some(snr); + } +} + /// Store a plain-text (non-typed) message and emit an event. pub(super) async fn store_plain_message( state: &Arc, diff --git a/core/archipelago/src/mesh/listener/frames.rs b/core/archipelago/src/mesh/listener/frames.rs index 3c568f9e..2dc6f266 100644 --- a/core/archipelago/src/mesh/listener/frames.rs +++ b/core/archipelago/src/mesh/listener/frames.rs @@ -5,7 +5,7 @@ use super::super::protocol; use super::decode::{ handle_identity_received, is_mc_chunk_frame, resolve_peer, store_plain_message, store_plain_message_with_encryption, try_base64_typed, try_chunk_reassemble, - try_decrypt_base64, try_decrypt_ratchet_base64, + try_decrypt_base64, try_decrypt_ratchet_base64, update_peer_snr, }; use super::dispatch::handle_typed_message; use super::MeshState; @@ -66,10 +66,11 @@ pub(super) async fn handle_frame( protocol::RESP_CONTACT_MSG_V3 | protocol::RESP_CONTACT_MSG_V3_E2E => { // Direct message received (v3 format) — check for typed envelope first match protocol::parse_contact_msg_v3_raw(&frame.data) { - Ok((sender_prefix, payload, _snr)) => { + Ok((sender_prefix, payload, snr)) => { if !payload.is_empty() { let encrypted = frame.code == protocol::RESP_CONTACT_MSG_V3_E2E; let (contact_id, name) = resolve_peer(state, &sender_prefix).await; + update_peer_snr(state, contact_id, snr as f32).await; if TypedEnvelope::is_typed(&payload) { handle_typed_message(&payload, contact_id, &name, state).await; } else if let Some(decoded) = try_base64_typed(&payload) { diff --git a/core/archipelago/src/mesh/listener/mod.rs b/core/archipelago/src/mesh/listener/mod.rs index 1bec5e39..666b2415 100644 --- a/core/archipelago/src/mesh/listener/mod.rs +++ b/core/archipelago/src/mesh/listener/mod.rs @@ -33,10 +33,20 @@ const SYNC_INTERVAL: Duration = Duration::from_secs(10); /// reconnect — the write-side `consecutive_write_failures` counter is blind /// to a receive-only stall (writes can keep succeeding while the radio's /// stopped streaming inbound, e.g. the FROM_RADIO_REBOOTED-without-recovery -/// case this same file already has a targeted fix for). 5 minutes is well -/// above the existing 60s advert / 10s sync cadence, so a healthy link never -/// trips it. -const RX_STALL_TIMEOUT: Duration = Duration::from_secs(300); +/// case meshtastic.rs already has a targeted, immediate fix for — this +/// watchdog is just the backstop for a device that goes silent WITHOUT +/// emitting that notification). +/// +/// 5 minutes was originally chosen on the (wrong) assumption that the 60s +/// advert / 10s sync cadence implies *received* traffic — those are our own +/// OUTBOUND cadences and say nothing about what peers send us. A quiet mesh +/// (no peer transmitting, or Reticulum/LXMF's point-to-point store-and- +/// forward model with no broadcast echo) can be legitimately RX-silent for +/// long stretches with the link perfectly healthy; at 300s this forced a +/// full auto-detect reconnect (visible in the UI as "Connecting…") every +/// ~5 minutes on otherwise-idle nodes. 30 minutes still catches a wedged +/// device in reasonable time without false-triggering on normal mesh quiet. +const RX_STALL_TIMEOUT: Duration = Duration::from_secs(1800); /// Maximum stored messages (circular buffer). const MAX_MESSAGES: usize = 100; diff --git a/core/archipelago/src/mesh/reticulum.rs b/core/archipelago/src/mesh/reticulum.rs index 97c700db..a0f72a89 100644 --- a/core/archipelago/src/mesh/reticulum.rs +++ b/core/archipelago/src/mesh/reticulum.rs @@ -25,6 +25,7 @@ use super::message_types::{self, ContentInlinePayload, MeshMessageType, TypedEnv use super::protocol::{self, InboundFrame, ParsedContact}; use super::types::DeviceInfo; use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::path::Path; @@ -48,6 +49,16 @@ const KISS_CMD_MCU: u8 = 0x49; const PROBE_BAUD: u32 = 115200; const PROBE_READ_TIMEOUT: Duration = Duration::from_millis(800); +/// Prefix marking an LXMF `content` string as base64 of a raw binary +/// typed-envelope payload rather than literal text. LXMF `content` travels +/// as a JSON string over the daemon RPC bridge, so a binary CBOR envelope +/// sent through `send_text_msg` as-is (single-frame path — anything chunked +/// is already base64'd before it gets here) must be lossily reinterpreted as +/// UTF-8 or it corrupts the wire and surfaces as garbage text on receipt. +/// Real plain-text chat never starts with this (its first byte would have +/// to be the `TypedEnvelope` marker 0x02), so the check is unambiguous. +const RETICULUM_BINARY_CONTENT_MARKER: &str = "\u{0}b64:"; + /// Path to the supervised daemon binary. In production this is the bundled /// PyInstaller artifact shipped beside `/usr/local/bin/archipelago` (Phase 1 /// packaging); during development it can point at the venv's interpreter @@ -111,6 +122,18 @@ struct ReticulumPeer { reachable: bool, } +/// On-disk shape of `ReticulumPeer` — `[u8; 16]` can't be a JSON object key, +/// so this stores the dest hash as hex instead. Persisted so a peer's +/// prefix->dest-hash mapping and display name survive a restart instead of +/// requiring a fresh announce every time (both `prefix_to_hash` and `peers` +/// are otherwise pure in-memory state rebuilt empty on every reconnect). +#[derive(Serialize, Deserialize)] +struct PersistedReticulumPeer { + dest_hash_hex: String, + display_name: String, + arch_pubkey_hex: Option, +} + /// Bridge handle to one supervised `reticulum-daemon` instance, one per active /// Reticulum (RNode) radio. Implements the same method shapes /// `MeshRadioDevice` calls on `MeshcoreDevice`/`MeshtasticDevice`. @@ -127,6 +150,9 @@ pub struct ReticulumLink { /// announces and `get_contacts`. prefix_to_hash: HashMap<[u8; 6], [u8; 16]>, peers: HashMap<[u8; 16], ReticulumPeer>, + /// Where `peers.json` lives (`{data_dir}/reticulum/peers.json`) — set once + /// at spawn, used by `persist_peers`/`load_persisted_peers`. + peers_file: std::path::PathBuf, inbound: std::collections::VecDeque, /// Monotonic correlation id for `send_resource` RPC calls — purely for /// matching `resource_progress`/`resource_sent`/`resource_failed` events @@ -248,7 +274,7 @@ impl ReticulumLink { "Reticulum daemon ready" ); - Ok(Self { + let mut link = Self { device_path: path.to_string(), socket_path, child, @@ -258,9 +284,68 @@ impl ReticulumLink { display_name, prefix_to_hash: HashMap::new(), peers: HashMap::new(), + peers_file: runtime_dir.join("peers.json"), inbound: std::collections::VecDeque::new(), resource_id_counter: 0, - }) + }; + link.load_persisted_peers(); + Ok(link) + } + + /// Repopulate `peers`/`prefix_to_hash` from disk so a restart doesn't + /// force every peer back to "Anonymous Peer" / unreachable until they + /// happen to re-announce or message us again. + fn load_persisted_peers(&mut self) { + let Ok(bytes) = std::fs::read(&self.peers_file) else { + return; + }; + let Ok(persisted) = serde_json::from_slice::>(&bytes) else { + warn!(path = %self.peers_file.display(), "Failed to parse persisted Reticulum peers"); + return; + }; + for p in persisted { + let Ok(hash) = parse_hash16(&p.dest_hash_hex) else { + continue; + }; + let prefix: [u8; 6] = hash[..6].try_into().unwrap(); + self.prefix_to_hash.insert(prefix, hash); + self.peers.insert( + hash, + ReticulumPeer { + dest_hash: hash, + display_name: p.display_name, + arch_pubkey_hex: p.arch_pubkey_hex, + // Reachability is a live property, not a persisted fact — + // start conservative and let the first real event refresh it. + reachable: false, + }, + ); + } + info!(count = self.peers.len(), "Loaded persisted Reticulum peers"); + } + + /// Best-effort sync write of the current peer table — called after any + /// insert that adds/renames a peer. Infrequent (announces/first-contact, + /// not per-message) so a blocking write here is a fine trade for keeping + /// this out of the async runtime's plumbing. + fn persist_peers(&self) { + let persisted: Vec = self + .peers + .values() + .map(|p| PersistedReticulumPeer { + dest_hash_hex: hex::encode(p.dest_hash), + display_name: p.display_name.clone(), + arch_pubkey_hex: p.arch_pubkey_hex.clone(), + }) + .collect(); + match serde_json::to_vec(&persisted) { + Ok(bytes) => { + if let Err(e) = std::fs::write(&self.peers_file, bytes) { + warn!(path = %self.peers_file.display(), "Failed to persist Reticulum peers: {}", e); + } + } + Err(e) => warn!("Failed to serialize Reticulum peers: {}", e), + } } fn next_resource_id(&mut self) -> String { @@ -319,10 +404,21 @@ impl ReticulumLink { hex::encode(dest_pubkey_prefix) ) })?; + // Typed-envelope payloads (ReadReceipt/Reaction/etc. — anything small + // enough for the single-frame path) are raw binary CBOR, not text. + // `from_utf8_lossy` would irreversibly mangle them since `content` + // round-trips as a JSON string; base64 instead so receive can recover + // the exact original bytes. + use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; + let content = if TypedEnvelope::is_typed(payload) { + format!("{RETICULUM_BINARY_CONTENT_MARKER}{}", B64.encode(payload)) + } else { + String::from_utf8_lossy(payload).into_owned() + }; self.send_rpc(serde_json::json!({ "cmd": "send", "dest_hash": hex::encode(dest_hash), - "content": String::from_utf8_lossy(payload), + "content": content, "method": "direct", })) .await @@ -560,6 +656,7 @@ impl ReticulumLink { arch_pubkey_hex: None, reachable: true, }); + self.persist_peers(); } Some("recv") => { let Some(source_hex) = ev.get("source_hash").and_then(Value::as_str) else { @@ -570,6 +667,19 @@ impl ReticulumLink { }; let prefix: [u8; 6] = source_hash[..6].try_into().unwrap(); self.prefix_to_hash.insert(prefix, source_hash); + // A peer that messages us without ever announcing still needs + // to survive a restart — give it a placeholder name (the real + // one, if any, arrives via a later "announce" and overwrites + // this) so its routing entry alone doesn't get lost. + if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash) { + e.insert(ReticulumPeer { + dest_hash: source_hash, + display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])), + arch_pubkey_hex: None, + reachable: true, + }); + self.persist_peers(); + } // A stock LXMF client (Sideband/NomadNet — not an archy peer) // carries photos/files in native LXMF fields, not our own @@ -615,12 +725,17 @@ impl ReticulumLink { } } - let content = ev - .get("content") - .and_then(Value::as_str) - .unwrap_or("") - .as_bytes() - .to_vec(); + let content_str = ev.get("content").and_then(Value::as_str).unwrap_or(""); + let content = match content_str.strip_prefix(RETICULUM_BINARY_CONTENT_MARKER) { + Some(b64) => match B64.decode(b64) { + Ok(bytes) => bytes, + Err(e) => { + warn!("Failed to decode binary Reticulum content: {}", e); + Vec::new() + } + }, + None => content_str.as_bytes().to_vec(), + }; self.inbound.push_back(build_synthetic_frame(&prefix, &content)); } Some("resource_recv") => { @@ -632,6 +747,15 @@ impl ReticulumLink { }; let prefix: [u8; 6] = source_hash[..6].try_into().unwrap(); self.prefix_to_hash.insert(prefix, source_hash); + if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash) { + e.insert(ReticulumPeer { + dest_hash: source_hash, + display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])), + arch_pubkey_hex: None, + reachable: true, + }); + self.persist_peers(); + } use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; let Some(data) = ev .get("data_b64") @@ -860,6 +984,42 @@ mod tests { assert!(!contains_detect_resp(&stream)); } + /// Regression test for the peer-identity persistence fix: a dest hash + /// round-tripped through `PersistedReticulumPeer` (hex on disk) via + /// `hex::encode`/`parse_hash16` — the exact pair `persist_peers`/ + /// `load_persisted_peers` use — must come back byte-for-byte identical, + /// or a restart would silently corrupt peer routing instead of just + /// losing it. + #[test] + fn persisted_peer_hex_roundtrip() { + let hash: [u8; 16] = [ + 0x18, 0x70, 0x74, 0x4d, 0x7c, 0x35, 0xa9, 0x2c, 0xf0, 0x61, 0xfb, 0x81, 0x0e, 0xf3, + 0x41, 0x65, + ]; + let persisted = vec![PersistedReticulumPeer { + dest_hash_hex: hex::encode(hash), + display_name: "zazaticulum".to_string(), + arch_pubkey_hex: Some("abcdef".to_string()), + }]; + + let dir = std::env::temp_dir().join(format!("archy-reticulum-peers-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("peers.json"); + std::fs::write(&path, serde_json::to_vec(&persisted).unwrap()).unwrap(); + + let loaded: Vec = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].display_name, "zazaticulum"); + assert_eq!(loaded[0].arch_pubkey_hex.as_deref(), Some("abcdef")); + let round_tripped_hash = parse_hash16(&loaded[0].dest_hash_hex).unwrap(); + assert_eq!(round_tripped_hash, hash); + let prefix: [u8; 6] = round_tripped_hash[..6].try_into().unwrap(); + assert_eq!(prefix, hash[..6]); + + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn contact_id_masks_high_bit_and_avoids_zero() { let hash_high_bit = { diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index 899bb73b..05b9bdeb 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -323,6 +323,9 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install polkitd \ openssh-server \ nginx \ + avahi-daemon \ + avahi-utils \ + libnss-mdns \ podman \ catatonit \ uidmap \ @@ -416,12 +419,16 @@ RUN ln -sf /etc/nginx/sites-available/archipelago /etc/nginx/sites-enabled/archi # Install nginx snippets (PWA config, HTTPS app proxies) COPY snippets/ /etc/nginx/snippets/ -# Generate self-signed SSL certificate for HTTPS (PWA install requires secure context) +# Generate self-signed SSL certificate for HTTPS (PWA install + mic/camera +# access both require a secure context). SAN covers the install-time default +# hostname -- server.set-name regenerates this with the new hostname's SAN +# if a node is renamed. RUN mkdir -p /etc/archipelago/ssl && \ openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ -keyout /etc/archipelago/ssl/archipelago.key \ -out /etc/archipelago/ssl/archipelago.crt \ - -subj "/C=XX/ST=Bitcoin/L=Node/O=Archipelago/CN=archipelago" && \ + -subj "/C=XX/ST=Bitcoin/L=Node/O=Archipelago/CN=archipelago" \ + -addext "subjectAltName=DNS:archipelago,DNS:archipelago.local,DNS:localhost,IP:127.0.0.1" && \ chmod 600 /etc/archipelago/ssl/archipelago.key # Create archipelago systemd service @@ -479,6 +486,7 @@ RUN systemctl enable NetworkManager || true && \ systemctl enable polkit || systemctl enable polkit.service || true && \ systemctl enable ssh || true && \ systemctl enable nginx || true && \ + systemctl enable avahi-daemon || true && \ systemctl enable archipelago || true && \ systemctl enable tor || true && \ systemctl enable tailscaled || true && \ @@ -3039,7 +3047,8 @@ if [ ! -f /mnt/target/etc/archipelago/ssl/archipelago.crt ]; then chroot /mnt/target openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ -keyout /etc/archipelago/ssl/archipelago.key \ -out /etc/archipelago/ssl/archipelago.crt \ - -subj "/C=XX/ST=Bitcoin/L=Node/O=Archipelago/CN=archipelago" 2>/dev/null + -subj "/C=XX/ST=Bitcoin/L=Node/O=Archipelago/CN=archipelago" \ + -addext "subjectAltName=DNS:archipelago,DNS:archipelago.local,DNS:localhost,IP:127.0.0.1" 2>/dev/null chmod 600 /mnt/target/etc/archipelago/ssl/archipelago.key echo " Generated self-signed SSL certificate" fi diff --git a/neode-ui/src/components/cloud/MediaLightbox.vue b/neode-ui/src/components/cloud/MediaLightbox.vue index e6088655..251cb568 100644 --- a/neode-ui/src/components/cloud/MediaLightbox.vue +++ b/neode-ui/src/components/cloud/MediaLightbox.vue @@ -416,5 +416,20 @@ onUnmounted(() => { .lightbox-nav-next { right: 0.5rem; } .lightbox-audio-artwork { width: 8rem; height: 8rem; } .lightbox-media-video { border-radius: 0; } + + /* The close button used to sit in the top bar, which lands under the + status bar / notch safe area on most phones and is awkward to reach. + Detach it from the top bar and pin it bottom-center, under the media, + for mobile only — desktop keeps it in the top bar. */ + .lightbox-topbar { padding-right: 1rem; } + .lightbox-topbar .lightbox-btn { + position: fixed; + top: auto; + bottom: calc(env(safe-area-inset-bottom, 0px) + 1rem); + left: 50%; + right: auto; + transform: translateX(-50%); + z-index: 20; + } } diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index cd30e753..f55c90a5 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -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 = { + '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([]) + +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 { + 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() +} +