diff --git a/src/app/mod.rs b/src/app/mod.rs index 72e661a..3a25827 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,7 +1,7 @@ use crate::core::{CoreController, messages::{CoreCommand, UiEvent}}; use crate::network::PeerState; use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices}; -use crate::config::AppConfig; +use crate::config::{AppConfig, NetworkMode}; use iced::widget::{ container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list, Column, @@ -40,6 +40,7 @@ pub enum AppMessage { InputDeviceSelected(AudioDevice), OutputDeviceSelected(AudioDevice), NoiseGateChanged(f32), + NetworkModeSelected(NetworkMode), EventOccurred(Event), NavigateToSettings, NavigateBack, @@ -91,6 +92,7 @@ impl Default for AppState { let config = AppConfig::load(); let _ = controller.send(CoreCommand::SetNoiseGateThreshold(config.noise_gate_threshold)); + let _ = controller.send(CoreCommand::SetNetworkMode(config.network_mode)); let all_devices = enumerate_audio_devices(); let input_devices: Vec<_> = all_devices.iter().filter(|d| d.is_input).cloned().collect(); let output_devices: Vec<_> = all_devices.iter().filter(|d| !d.is_input).cloned().collect(); @@ -258,6 +260,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.config.save(); let _ = state.controller.send(CoreCommand::SetNoiseGateThreshold(val)); } + AppMessage::NetworkModeSelected(mode) => { + state.config.network_mode = mode; + state.config.save(); + // Applied on the next join, since the endpoint is rebuilt then. + let _ = state.controller.send(CoreCommand::SetNetworkMode(mode)); + } AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => { if state.is_setting_hotkey { state.ptt_hotkey = key.clone(); @@ -288,6 +296,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { Task::none() } +/// One-line explanation of a network posture for the settings picker. +fn network_mode_hint(mode: NetworkMode) -> &'static str { + match mode { + NetworkMode::RelayNoDiscovery => "n0 relay for NAT traversal; no presence published to n0 DNS.", + NetworkMode::N0Full => "n0 relay + DNS discovery. Most reliable, most metadata shared.", + NetworkMode::DirectOnly => "Fully serverless. May fail behind strict/CGNAT networks.", + } +} + fn horizontal_space() -> iced::widget::Space { iced::widget::Space::new().width(iced::Length::Fill) } @@ -396,6 +413,17 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { text(format!("Mic Sensitivity (Noise Gate): {:.1}%", state.config.noise_gate_threshold * 100.0)).size(14).color(color_subtext), slider(0.0..=0.1, state.config.noise_gate_threshold, AppMessage::NoiseGateChanged).step(0.001) ].spacing(10).width(iced::Length::Fixed(320.0)), + vertical_space(20.0), + column![ + text("Network Privacy").size(14).color(color_subtext), + pick_list( + &NetworkMode::ALL[..], + Some(state.config.network_mode), + AppMessage::NetworkModeSelected, + ).width(iced::Length::Fixed(320.0)), + text(network_mode_hint(state.config.network_mode)).size(11).color(color_subtext), + text("Takes effect on your next room join.").size(11).color(color_surface), + ].spacing(6).width(iced::Length::Fixed(320.0)), vertical_space(30.0), button( text("Back") diff --git a/src/config.rs b/src/config.rs index d87e3da..3468231 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,11 +2,47 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; +/// Relay/discovery posture, trading connectivity against how much the n0 +/// infrastructure learns about you. See the network module for details. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum NetworkMode { + /// n0 relay for NAT traversal, but no DNS presence beacon. Peer addresses + /// come from the join ticket and gossip, so n0 only sees relayed-call + /// metadata, never a standing "I'm online" record. Default. + #[default] + RelayNoDiscovery, + /// Full n0 defaults: relay plus DNS publish/resolve (most convenient, + /// most phone-home). + N0Full, + /// No relay, no discovery: direct hole-punching only. Fully serverless, + /// but fails behind symmetric/CGNAT NATs with no fallback. + DirectOnly, +} + +impl NetworkMode { + /// All variants, for presentation in a picker. + pub const ALL: [NetworkMode; 3] = + [NetworkMode::RelayNoDiscovery, NetworkMode::N0Full, NetworkMode::DirectOnly]; +} + +impl std::fmt::Display for NetworkMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let label = match self { + NetworkMode::RelayNoDiscovery => "Relay, no presence beacon", + NetworkMode::N0Full => "n0 defaults (relay + DNS)", + NetworkMode::DirectOnly => "Direct only (no relay)", + }; + f.write_str(label) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { pub input_device: String, pub output_device: String, pub noise_gate_threshold: f32, + #[serde(default)] + pub network_mode: NetworkMode, } impl Default for AppConfig { @@ -15,6 +51,7 @@ impl Default for AppConfig { input_device: "".to_string(), output_device: "".to_string(), noise_gate_threshold: 0.01, + network_mode: NetworkMode::default(), } } } diff --git a/src/core/messages.rs b/src/core/messages.rs index 1d8e02b..5d535e0 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -1,3 +1,4 @@ +use crate::config::NetworkMode; use crate::network::PeerState; use iroh::EndpointId; @@ -11,6 +12,9 @@ pub enum CoreCommand { SetPttActive(bool), SetPeerVolume(EndpointId, f32), SetNoiseGateThreshold(f32), + /// Set the relay/discovery posture. Takes effect on the next room join, + /// since the endpoint is (re)built then. + SetNetworkMode(NetworkMode), } #[derive(Debug, Clone)] diff --git a/src/core/mod.rs b/src/core/mod.rs index 4a3df83..61c3641 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -11,7 +11,8 @@ use crate::network::{ }; use crate::core::messages::{CoreCommand, UiEvent}; -use iroh::{Endpoint, EndpointId, endpoint::presets, protocol::Router}; +use crate::config::NetworkMode; +use iroh::{Endpoint, EndpointId, RelayMode, endpoint::presets, protocol::Router}; use iroh_gossip::net::Gossip; use tokio::sync::{mpsc, Mutex}; use std::collections::HashMap; @@ -100,6 +101,7 @@ async fn run_core_loop( let noise_gate_threshold = Arc::new(std::sync::atomic::AtomicU32::new(0.01f32.to_bits())); let peer_volumes = Arc::new(Mutex::new(HashMap::::new())); let mut current_name = "Anonymous".to_string(); + let mut network_mode = NetworkMode::default(); let mut active_session: Option = None; @@ -114,17 +116,43 @@ async fn run_core_loop( session.shutdown(audio_backend.clone()).await; } - let endpoint = match Endpoint::builder(presets::N0) - .secret_key(secret_key.clone()) - .address_lookup(memory_lookup.clone()) - .bind() - .await { - Ok(ep) => ep, - Err(e) => { - let _ = ui_tx.send(UiEvent::Error(format!("Failed to bind endpoint: {}", e))).await; - continue; - } - }; + // Build the endpoint per the configured relay/discovery posture. + // All postures keep the in-memory address lookup (fed by tickets + // and gossip); they differ in whether n0's relay and DNS presence + // beacon are used. `Minimal` sets only the mandatory crypto + // provider and deliberately omits the n0 DNS publish/resolve. + let bind_result = match network_mode { + NetworkMode::N0Full => { + Endpoint::builder(presets::N0) + .secret_key(secret_key.clone()) + .address_lookup(memory_lookup.clone()) + .bind() + .await + } + NetworkMode::RelayNoDiscovery => { + Endpoint::builder(presets::Minimal) + .secret_key(secret_key.clone()) + .relay_mode(RelayMode::Default) + .address_lookup(memory_lookup.clone()) + .bind() + .await + } + NetworkMode::DirectOnly => { + Endpoint::builder(presets::Minimal) + .secret_key(secret_key.clone()) + .relay_mode(RelayMode::Disabled) + .address_lookup(memory_lookup.clone()) + .bind() + .await + } + }; + let endpoint = match bind_result { + Ok(ep) => ep, + Err(e) => { + let _ = ui_tx.send(UiEvent::Error(format!("Failed to bind endpoint: {}", e))).await; + continue; + } + }; endpoint.online().await; // Determine target ticket @@ -443,6 +471,10 @@ async fn run_core_loop( CoreCommand::SetNoiseGateThreshold(threshold) => { noise_gate_threshold.store(threshold.to_bits(), Ordering::Relaxed); } + + CoreCommand::SetNetworkMode(mode) => { + network_mode = mode; + } } }