From deb846be1439d1d73dc6e12de3f8c8281b14cac6 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 14 Sep 2026 12:15:45 +0200 Subject: [PATCH] feat(tui): say so in the status bar when the listener took another port With the login screen's own attempts no longer holding the port, a session listens somewhere other than the configured port mainly when another program already holds it. soulseek-rs still falls back to an ephemeral port there, which is what lets several clients on one machine share a configured port, and it advertises the port it really bound. A manual port forward on the router then points at nothing, though, and the TUI never said so; the CLI already warns. After login the TUI compares the bound port with the configured one, using the CLI's rule, and the status bar's title reads "port X in use, listening on Y" for the rest of the session. A window attached to a daemon shows nothing: the daemon owns its listener. Nicotine+ keeps retrying the configured port every five seconds and Soulseek.NET refuses it; neither falls back. Keeping the fallback and making it visible is a deliberate difference. --- soulseek-rs/src/run.rs | 13 +++++++++---- soulseek-rs/src/ui/downloads.rs | 8 +++++++- soulseek-rs/src/ui/main_tui/mod.rs | 6 +++++- soulseek-rs/src/ui/main_tui/render/mod.rs | 1 + soulseek-rs/src/ui/main_tui/tests/layout.rs | 13 +++++++++++++ 5 files changed, 35 insertions(+), 6 deletions(-) diff --git a/soulseek-rs/src/run.rs b/soulseek-rs/src/run.rs index b0e8b58..eeecf45 100644 --- a/soulseek-rs/src/run.rs +++ b/soulseek-rs/src/run.rs @@ -502,14 +502,17 @@ fn run_default_tui( // Best-effort: make ourselves reachable behind a home router so // firewalled peers can connect back. Mapped only once the listener is // up, and for the port it really bound. Kept alive for the session. - let _port_mapper = outcome - .client - .listen_port() - .map(crate::port_mapping::PortMapper::spawn); + let bound_port = outcome.client.listen_port(); + let _port_mapper = + bound_port.map(crate::port_mapping::PortMapper::spawn); let store = crate::persist::paths::state_dir() .map(crate::persist::state::StateStore::new); + let listener_fallback = bound_port + .filter(|&bound| listen_port != 0 && bound != listen_port) + .map(|bound| (listen_port, bound)); + let exit = launch_main_tui( terminal, Arc::new(outcome.client), @@ -517,6 +520,7 @@ fn run_default_tui( Duration::from_secs(resolved.search_timeout), store, config_path.clone(), + listener_fallback, ) .map_err(|e| CliError::new(Exit::Failure, e.to_string()))?; @@ -592,6 +596,7 @@ fn run_attached_tui( Duration::from_secs(resolved.search_timeout), None, config_path, + None, ) .map(|_| ()) .map_err(|e| CliError::new(Exit::Failure, e.to_string())) diff --git a/soulseek-rs/src/ui/downloads.rs b/soulseek-rs/src/ui/downloads.rs index 0747f76..f79f718 100644 --- a/soulseek-rs/src/ui/downloads.rs +++ b/soulseek-rs/src/ui/downloads.rs @@ -12,6 +12,7 @@ use ratatui::{ widgets::Paragraph, }; use soulseek_rs::DownloadStatus; +use std::fmt::Write as _; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -54,6 +55,7 @@ pub fn render_download_stats( downloads: &[DownloadEntry], active_count: usize, daemon: Option<&str>, + listener_fallback: Option<(u16, u16)>, ) { let completed = downloads .iter() @@ -79,10 +81,14 @@ pub fn render_download_stats( let active = active_progress(downloads); - let title = match daemon { + let mut title = match daemon { Some(endpoint) => format!("Status · daemon {endpoint}"), None => "Status".to_string(), }; + if let Some((configured, bound)) = listener_fallback { + let _ = + write!(title, " · port {configured} in use, listening on {bound}"); + } let block = pane_block(false).title(plain_title(&title, false)); let inner_area = block.inner(area); diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 3658f4c..cf5d31e 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -33,6 +33,7 @@ pub struct MainTui { saved_snapshot: Snapshot, /// Which divider the left mouse button is holding, if any. resize_drag: Option, + listener_fallback: Option<(u16, u16)>, } impl MainTui { @@ -53,6 +54,7 @@ impl MainTui { config_path, saved_snapshot: Snapshot::default(), resize_drag: None, + listener_fallback: None, }; tui.restore_persisted_state(); tui @@ -257,9 +259,11 @@ pub fn launch_main_tui( search_timeout: Duration, store: Option, config_path: Option, + listener_fallback: Option<(u16, u16)>, ) -> Result { - let tui = + let mut tui = MainTui::new(client, download_dir, search_timeout, store, config_path); + tui.listener_fallback = listener_fallback; tui.run(terminal) } diff --git a/soulseek-rs/src/ui/main_tui/render/mod.rs b/soulseek-rs/src/ui/main_tui/render/mod.rs index bf16054..98cd750 100644 --- a/soulseek-rs/src/ui/main_tui/render/mod.rs +++ b/soulseek-rs/src/ui/main_tui/render/mod.rs @@ -40,6 +40,7 @@ impl MainTui { &self.state.downloads, self.state.active_downloads_count, daemon.as_deref(), + self.listener_fallback, ); self.render_content(frame, main_chunks[1]); diff --git a/soulseek-rs/src/ui/main_tui/tests/layout.rs b/soulseek-rs/src/ui/main_tui/tests/layout.rs index 8381637..1ea058f 100644 --- a/soulseek-rs/src/ui/main_tui/tests/layout.rs +++ b/soulseek-rs/src/ui/main_tui/tests/layout.rs @@ -21,6 +21,19 @@ fn the_results_pane_has_the_whole_width_to_itself() { assert!(searches < downloads && downloads < info, "{row}"); } +#[test] +fn a_listener_that_fell_back_says_so_in_the_status_bar() { + let mut tui = furnished_tui(); + tui.listener_fallback = Some((2234, 50123)); + + let screen = screen_of(&mut tui); + + assert!( + screen.contains("port 2234 in use, listening on 50123"), + "{screen}" + ); +} + #[test] fn tab_and_shift_tab_walk_the_panes_in_legend_order() { let mut tui = furnished_tui();