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:
parent
99cd82ab0a
commit
bebf3bae10
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 `<hostname>.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<serde_json::Value> {
|
||||
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<serde_json::Value> {
|
||||
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).
|
||||
|
||||
@ -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<MeshState>, 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<MeshState>,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<InboundFrame>,
|
||||
/// 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::<Vec<PersistedReticulumPeer>>(&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<PersistedReticulumPeer> = 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<PersistedReticulumPeer> =
|
||||
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 = {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -341,9 +341,18 @@
|
||||
.mesh-typed-coordinate-link { display: inline-block; font-size: 0.75rem; color: #3b82f6; margin-top: 4px; text-decoration: underline; }
|
||||
.typed-block_header { border-left: 3px solid #a855f7; }
|
||||
.mesh-typed-block { display: flex; align-items: center; gap: 4px; color: #a855f7; font-size: 0.8rem; }
|
||||
.mesh-typed-content-preview { max-width: 220px; max-height: 220px; border-radius: 10px; display: block; }
|
||||
.mesh-typed-content-preview { max-width: 220px; max-height: 220px; border-radius: 10px; display: block; cursor: pointer; }
|
||||
.mesh-typed-content-thumb { opacity: 0.85; filter: blur(0.5px); margin-bottom: 6px; }
|
||||
.mesh-typed-content-audio { width: 220px; max-width: 100%; display: block; }
|
||||
.mesh-typed-content-image-wrap { position: relative; display: inline-block; }
|
||||
.mesh-typed-content-download-btn {
|
||||
position: absolute; bottom: 6px; right: 6px; width: 1.75rem; height: 1.75rem;
|
||||
border-radius: 50%; border: 1px solid rgba(255,255,255,0.15);
|
||||
background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.85); font-size: 0.85rem;
|
||||
display: flex; align-items: center; justify-content: center; cursor: pointer;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.mesh-typed-content-download-btn:hover { background: rgba(0,0,0,0.75); color: #fff; }
|
||||
.mesh-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; }
|
||||
.mesh-tab { flex: 1; padding: 8px 12px; border: none; background: transparent; color: rgba(255,255,255,0.5); font-size: 0.82rem; font-weight: 500; border-radius: 8px; cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; gap: 6px; }
|
||||
.mesh-tab:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); }
|
||||
@ -368,7 +377,7 @@
|
||||
.mesh-assistant-addkey input { flex: 1; min-width: 0; }
|
||||
.mesh-panel-title { font-size: 1rem; font-weight: 700; color: rgba(255,255,255,0.95); margin: 0; }
|
||||
.mesh-panel-sub { font-size: 0.8rem; color: rgba(255,255,255,0.45); margin: -4px 0 0; }
|
||||
.mesh-device-panel { display: flex; flex-direction: column; gap: 12px; }
|
||||
.mesh-device-panel { padding: 16px; display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; overflow-y: auto; }
|
||||
.mesh-device-panel-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
.mesh-device-panel-actions { display: flex; flex-direction: column; gap: 6px; padding-top: 4px; border-top: 1px solid rgba(255,255,255,0.08); }
|
||||
.mesh-device-reboot-btn { align-self: flex-start; padding: 8px 16px; font-size: 0.85rem; }
|
||||
|
||||
@ -68,6 +68,22 @@ const copiedOnion = ref(false)
|
||||
const copiedDid = ref(false)
|
||||
let copiedTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// mDNS hostname — HTTPS (even self-signed) is required for mic/camera access
|
||||
// (getUserMedia refuses plain HTTP outside localhost); surface both so users
|
||||
// know where to go for features that need it, without forcing HTTPS on anyone.
|
||||
const mdnsHostname = ref<string | null>(null)
|
||||
const httpsUrl = computed(() => (mdnsHostname.value ? `https://${mdnsHostname.value}` : null))
|
||||
const httpUrl = computed(() => (mdnsHostname.value ? `http://${mdnsHostname.value}` : null))
|
||||
const copiedHttps = ref(false)
|
||||
async function copyHttpsUrl() {
|
||||
if (!httpsUrl.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(httpsUrl.value)
|
||||
copiedHttps.value = true
|
||||
setTimeout(() => { copiedHttps.value = false }, 2000)
|
||||
} catch { /* unavailable */ }
|
||||
}
|
||||
|
||||
async function copyOnionAddress() {
|
||||
const addr = serverTorAddress.value
|
||||
if (!addr) return
|
||||
@ -150,6 +166,13 @@ async function init() {
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('node.nostr-pubkey unavailable', e)
|
||||
}
|
||||
// mDNS hostname for the "Access this node" card.
|
||||
try {
|
||||
const res = await rpcClient.call<{ mdns_hostname?: string }>({ method: 'system.get-hostname' })
|
||||
if (res?.mdns_hostname) mdnsHostname.value = res.mdns_hostname
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('system.get-hostname unavailable', e)
|
||||
}
|
||||
}
|
||||
init()
|
||||
</script>
|
||||
@ -215,6 +238,28 @@ init()
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access This Node Card — local hostname + HTTP/HTTPS. HTTPS (even
|
||||
self-signed) is needed for mic/camera access on some features; never
|
||||
forced, just surfaced so people who need it know where to go. -->
|
||||
<div v-if="mdnsHostname" class="bg-black/20 rounded-xl px-5 py-4 border border-white/10">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728m-9.9-2.829a5 5 0 010-7.07m7.072 0a5 5 0 010 7.07M13 12a1 1 0 11-2 0 1 1 0 012 0z" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Access on this network</p>
|
||||
</div>
|
||||
<p class="text-lg font-semibold text-white/95 mb-1">{{ mdnsHostname }}</p>
|
||||
<div class="flex items-center gap-2 text-xs text-white/50">
|
||||
<a :href="httpUrl!" class="hover:text-white/80 transition-colors underline">http</a>
|
||||
<span>·</span>
|
||||
<a :href="httpsUrl!" class="hover:text-white/80 transition-colors underline">https</a>
|
||||
<span class="text-white/30">(needed for mic/camera features)</span>
|
||||
<button @click="copyHttpsUrl" class="ml-auto text-white/40 hover:text-white/70 transition-colors">
|
||||
{{ copiedHttps ? 'Copied!' : 'Copy HTTPS link' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Release Notes Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user