diff --git a/release/SHA256SUMS b/release/SHA256SUMS index 03e1106..3f5a623 100644 --- a/release/SHA256SUMS +++ b/release/SHA256SUMS @@ -1 +1 @@ -a5df5c25c2fb13ff7d3486c6214371dac540ccaa70094e02e458bfd0b5e6b367 release/mycellium-ui_0.1.0_amd64.deb +acab8758c4ba7f3894e5e9bce28cbd759b8ec7e08a1ee00f54f6fdcac75a4221 release/mycellium-ui_0.1.0_amd64.deb diff --git a/release/mycellium-ui_0.1.0_amd64.deb b/release/mycellium-ui_0.1.0_amd64.deb index eee8402..3750483 100644 Binary files a/release/mycellium-ui_0.1.0_amd64.deb and b/release/mycellium-ui_0.1.0_amd64.deb differ diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 35e8e2f..08b74bc 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -14,11 +14,16 @@ use serde::Serialize; use tauri::{AppHandle, State}; #[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] pub struct DaemonStatus { pub running: bool, pub api_url: Option, pub key_path: Option, pub config_path: Option, + /// Full overlay IPv6 (e.g. `43d:956e:7877:d933:eecc:b305:21ff:77f9`). + /// Surfaced separately from `nodeSubnet` so the UI can show users the + /// address they should actually use as a message destination. + pub overlay_ip: Option, } fn snapshot(state: &AppState) -> DaemonStatus { @@ -28,6 +33,7 @@ fn snapshot(state: &AppState) -> DaemonStatus { api_url: sc.client().map(|c| c.base_url().to_string()), key_path: sc.key_path().map(|p| p.display().to_string()), config_path: sc.config_path().map(|p| p.display().to_string()), + overlay_ip: sc.overlay_ip(), } } diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs index 7343264..a1d0258 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -48,6 +48,10 @@ pub struct SidecarHandle { logs: Mutex>, config_path: Mutex>, key_path: Mutex>, + /// Full overlay IPv6, parsed out of mycelium's `Node overlay IP: ...` + /// startup log line. The daemon's HTTP API only exposes the subnet, + /// so we extract the host part from the binary's stderr. + overlay_ip: Mutex>, } impl SidecarHandle { @@ -58,6 +62,7 @@ impl SidecarHandle { logs: Mutex::new(VecDeque::with_capacity(LOG_RING_CAPACITY)), config_path: Mutex::new(None), key_path: Mutex::new(None), + overlay_ip: Mutex::new(None), }) } @@ -84,7 +89,21 @@ impl SidecarHandle { self.config_path.lock().clone() } + pub fn overlay_ip(&self) -> Option { + self.overlay_ip.lock().clone() + } + fn push_log(&self, line: String) { + // mycelium prefixes log lines with ANSI colour escapes; strip them + // before scanning for the overlay IP marker so the regex stays + // resilient to cosmetic changes in upstream's log format. + if self.overlay_ip.lock().is_none() { + let stripped = strip_ansi(&line); + if let Some(ip) = extract_overlay_ip(&stripped) { + tracing::info!(overlay_ip = %ip, "node overlay IP captured"); + *self.overlay_ip.lock() = Some(ip); + } + } let mut buf = self.logs.lock(); if buf.len() >= LOG_RING_CAPACITY { buf.pop_front(); @@ -262,6 +281,7 @@ impl SidecarHandle { fn cleanup(&self) { *self.api_url.lock() = None; + *self.overlay_ip.lock() = None; let _ = self.child.lock().take(); } @@ -279,6 +299,48 @@ impl SidecarHandle { } } +/// Mycelium logs the assigned overlay IPv6 once at startup like: +/// `INFO mycelium: Node overlay IP: 43d:956e:7877:d933:eecc:b305:21ff:77f9` +/// We don't pull a regex crate just for this — a hand-rolled parser is +/// resilient enough. +fn extract_overlay_ip(line: &str) -> Option { + const NEEDLE: &str = "Node overlay IP:"; + let idx = line.find(NEEDLE)?; + let rest = line[idx + NEEDLE.len()..].trim(); + // The IP is the first whitespace-bounded token; rejection criteria + // (must contain `:`, must not contain `/`) keep us from accidentally + // capturing the subnet. + let token = rest.split_whitespace().next()?; + if !token.contains(':') || token.contains('/') { + return None; + } + Some(token.to_string()) +} + +/// Strip ANSI SGR escape sequences (`ESC [ ... m`) from a log line so the +/// overlay-IP scanner doesn't choke on tracing's coloured output. +fn strip_ansi(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = String::with_capacity(s.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'[' { + // Skip until 'm' or any letter terminator. + i += 2; + while i < bytes.len() && !bytes[i].is_ascii_alphabetic() { + i += 1; + } + if i < bytes.len() { + i += 1; + } + } else { + out.push(bytes[i] as char); + i += 1; + } + } + out +} + fn pick_port() -> AppResult { portpicker::pick_unused_port().ok_or_else(|| AppError::Other("no free port available".into())) } diff --git a/src/lib/api.ts b/src/lib/api.ts index 16d0e35..047d852 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -7,6 +7,8 @@ export interface DaemonStatus { apiUrl: string | null; keyPath: string | null; configPath: string | null; + /** Full overlay IPv6 (e.g. `43d:956e:7877:d933:eecc:b305:21ff:77f9`). */ + overlayIp: string | null; } export interface NodeInfo { diff --git a/src/stores/node.ts b/src/stores/node.ts index ed070fa..e578f69 100644 --- a/src/stores/node.ts +++ b/src/stores/node.ts @@ -19,7 +19,13 @@ export const useNodeStore = defineStore("node", () => { exitedUnlisten = await on(Events.SidecarExited, (e) => { error.value = `daemon exited (code ${e.payload})`; phase.value = "error"; - status.value = { running: false, apiUrl: null, keyPath: null, configPath: null }; + status.value = { + running: false, + apiUrl: null, + keyPath: null, + configPath: null, + overlayIp: null, + }; info.value = null; }); } diff --git a/src/views/Status.vue b/src/views/Status.vue index c56b5c4..e41159d 100644 --- a/src/views/Status.vue +++ b/src/views/Status.vue @@ -23,6 +23,13 @@ async function copy(field: string, value: string) {