From 07cce8ec0dfe9db5c14375f1146a1369eeb218ee Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 20 Apr 2026 04:39:54 -0700 Subject: [PATCH] update on network change --- Cargo.lock | 1 + Cargo.toml | 3 + src/app.rs | 163 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 160 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a758943..42e8f07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1169,6 +1169,7 @@ dependencies = [ "reqwest", "rust-embed", "tokio", + "zbus 5.13.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0b015d8..9b48368 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,9 @@ rust-embed = "8.8.0" # Web requests for weather reqwest = { version = "0.13.1", features = [] } +# D-Bus for NetworkManager connectivity monitoring +zbus = { version = "5", default-features = false, features = ["tokio"] } + [dependencies.libcosmic] git = "https://github.com/pop-os/libcosmic.git" default-features = false diff --git a/src/app.rs b/src/app.rs index 2769405..19ab3d4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,11 +9,33 @@ use cosmic::widget::{self, autosize, container, rectangle_tracker::{ use cosmic::{iced_futures, prelude::*, surface}; use cosmic::applet::cosmic_panel_config::PanelAnchor; use cosmic::iced_core::Shadow; -use futures_util::SinkExt; +use futures_util::{SinkExt, StreamExt}; use std::time::Duration; const WTTR_URL: &str = "https://wttr.lerch.org"; const WEATHER_UPDATE_INTERVAL_MINUTES: u64 = 15; +const REQUEST_TIMEOUT_SECS: u64 = 15; +const MAX_RETRIES: u32 = 10; +const INITIAL_RETRY_DELAY_SECS: u64 = 5; +const MAX_RETRY_DELAY_SECS: u64 = 120; +/// Minimum time between acting on `NetworkManager` signals (debounce). +const NM_DEBOUNCE_SECS: u64 = 5; +/// Delay after NM reports connected before we fetch, to let DNS/routing settle. +const NM_SETTLE_DELAY_SECS: u64 = 2; + +/// D-Bus proxy for monitoring `NetworkManager` connectivity changes. +#[zbus::proxy( + interface = "org.freedesktop.NetworkManager", + default_service = "org.freedesktop.NetworkManager", + default_path = "/org/freedesktop/NetworkManager" +)] +trait NetworkManager { + /// Signal emitted when the overall NM connection state changes. + /// `state` values: 70 = Connected (global), 60 = Connected (site), + /// 50 = Connected (local), 40 = Connecting, etc. + #[zbus(signal)] + fn state_changed(&self, state: u32); +} /// The applet model stores app-specific state. pub struct AppModel { @@ -35,6 +57,8 @@ pub struct AppModel { rectangle: Rectangle, /// Rectangle tracker rectangle_tracker: Option>, + /// Number of consecutive fetch retries (for captive portal / network issues) + retry_count: u32, } /// Messages emitted by the applet. @@ -43,6 +67,7 @@ pub enum Message { WeatherUpdate(Result<(String, String), String>), FullWeatherUpdate(Result), RefreshWeather, + NetworkChanged, TogglePopup, CloseRequested(window::Id), Rectangle(RectangleUpdate), @@ -77,6 +102,7 @@ impl cosmic::Application for AppModel { popup: None, rectangle: Rectangle::default(), rectangle_tracker: None, + retry_count: 0, }; let command = Task::perform(fetch_weather(), |result| { @@ -214,6 +240,7 @@ impl cosmic::Application for AppModel { fn subscription(&self) -> Subscription { Subscription::batch([ rectangle_tracker_subscription(0).map(|e| Message::Rectangle(e.1)), + // Periodic weather refresh timer Subscription::run(|| { iced_futures::stream::channel(1, |mut emitter| async move { let mut interval = tokio::time::interval(Duration::from_secs(WEATHER_UPDATE_INTERVAL_MINUTES * 60)); @@ -225,6 +252,51 @@ impl cosmic::Application for AppModel { } }) }), + // NetworkManager connectivity change monitor + Subscription::run(|| { + iced_futures::stream::channel(1, |mut emitter| async move { + let connection = match zbus::Connection::system().await { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to connect to system D-Bus for network monitoring: {e}"); + futures_util::future::pending::<()>().await; + unreachable!(); + } + }; + + let proxy = match NetworkManagerProxy::new(&connection).await { + Ok(p) => p, + Err(e) => { + eprintln!("Failed to create NetworkManager proxy: {e}"); + futures_util::future::pending::<()>().await; + unreachable!(); + } + }; + + let mut stream = match proxy.receive_state_changed().await { + Ok(s) => s, + Err(e) => { + eprintln!("Failed to subscribe to NM StateChanged: {e}"); + futures_util::future::pending::<()>().await; + unreachable!(); + } + }; + + let mut last_emit = tokio::time::Instant::now() - Duration::from_secs(NM_DEBOUNCE_SECS + 1); + while let Some(signal) = stream.next().await { + if let Ok(args) = signal.args() { + // NM state >= 60 means some form of network connectivity + if args.state >= 60 && last_emit.elapsed() >= Duration::from_secs(NM_DEBOUNCE_SECS) { + // Brief delay to let DNS/routing stabilize + tokio::time::sleep(Duration::from_secs(NM_SETTLE_DELAY_SECS)).await; + _ = emitter.send(Message::NetworkChanged).await; + last_emit = tokio::time::Instant::now(); + } + } + } + eprintln!("NetworkManager signal stream ended unexpectedly"); + }) + }), ]) } @@ -237,14 +309,33 @@ impl cosmic::Application for AppModel { self.location = location; self.weather_text = weather; self.last_updated = format_current_time(); + self.retry_count = 0; } Err(e) => { eprintln!("Weather fetch error: {e}"); - self.weather_text = if e.contains("network") || e.contains("timeout") { - "🌐❌".to_string() // Network issue + self.weather_text = if e.contains("network") || e.contains("timeout") || e.contains("captive") || e.contains("Captive") { + "\u{1f310}\u{274c}".to_string() // Network issue } else { - "❌".to_string() // Generic error + "\u{274c}".to_string() // Generic error }; + + // Retry with exponential backoff (handles captive portals, + // transient failures after network changes, etc.) + if self.retry_count < MAX_RETRIES { + self.retry_count += 1; + let delay = retry_delay(self.retry_count); + eprintln!( + "Scheduling retry {}/{MAX_RETRIES} in {delay}s", + self.retry_count + ); + return Task::perform( + fetch_weather_delayed(delay), + |result| cosmic::Action::App(Message::WeatherUpdate(result)), + ); + } + eprintln!( + "Max retries ({MAX_RETRIES}) reached, waiting for next scheduled refresh" + ); } } Task::none() @@ -262,6 +353,16 @@ impl cosmic::Application for AppModel { Task::none() } Message::RefreshWeather => { + self.retry_count = 0; + Task::perform(fetch_weather(), |result| { + cosmic::Action::App(Message::WeatherUpdate(result)) + }) + } + Message::NetworkChanged => { + eprintln!("Network connectivity changed, refreshing weather"); + self.retry_count = 0; + // Clear cached full weather so the popup re-fetches on next open + self.full_weather.clear(); Task::perform(fetch_weather(), |result| { cosmic::Action::App(Message::WeatherUpdate(result)) }) @@ -355,12 +456,25 @@ impl AppModel { } async fn fetch_weather() -> Result<(String, String), String> { - let response = reqwest::get(&format!("{WTTR_URL}/?format=%l|%c|%t")) + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)) + .build() + .map_err(|e| e.to_string())?; + + let response = client + .get(format!("{WTTR_URL}/?format=%l|%c|%t")) + .send() .await .map_err(|e| e.to_string())?; let text = response.text().await.map_err(|e| e.to_string())?; let text = text.trim(); + + // Detect captive portal: the weather API returns plain text, not HTML. + if looks_like_captive_portal(text) { + return Err("Captive portal detected: received HTML instead of weather data".to_string()); + } + // Format: "location|icon|temp" let parts: Vec<&str> = text.splitn(3, '|').collect(); if parts.len() == 3 { @@ -388,8 +502,43 @@ fn format_current_time() -> String { } async fn fetch_full_weather() -> Result { - let response = reqwest::get(&format!("{WTTR_URL}?A&T")).await.map_err(|e| e.to_string())?; + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)) + .build() + .map_err(|e| e.to_string())?; + + let response = client + .get(format!("{WTTR_URL}?A&T")) + .send() + .await + .map_err(|e| e.to_string())?; let text = response.text().await.map_err(|e| e.to_string())?; - Ok(text.trim().to_string()) + let text = text.trim(); + + if looks_like_captive_portal(text) { + return Err("Captive portal detected: received HTML instead of weather data".to_string()); + } + + Ok(text.to_string()) +} + +/// Returns `true` if the response body looks like an HTML captive portal page +/// rather than the expected plain-text weather data. +fn looks_like_captive_portal(body: &str) -> bool { + let lower = body.to_ascii_lowercase(); + lower.contains(" u64 { + let delay = INITIAL_RETRY_DELAY_SECS.saturating_mul(2u64.saturating_pow(retry_count - 1)); + delay.min(MAX_RETRY_DELAY_SECS) +} + +/// Fetch weather after waiting `delay_secs`, used for retry backoff. +async fn fetch_weather_delayed(delay_secs: u64) -> Result<(String, String), String> { + tokio::time::sleep(Duration::from_secs(delay_secs)).await; + fetch_weather().await }