update on network change

This commit is contained in:
Emil Lerch 2026-04-20 04:39:54 -07:00
parent 2153f7b52b
commit 07cce8ec0d
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 160 additions and 7 deletions

1
Cargo.lock generated
View file

@ -1169,6 +1169,7 @@ dependencies = [
"reqwest",
"rust-embed",
"tokio",
"zbus 5.13.2",
]
[[package]]

View file

@ -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

View file

@ -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<RectangleTracker<u32>>,
/// 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<String, String>),
RefreshWeather,
NetworkChanged,
TogglePopup,
CloseRequested(window::Id),
Rectangle(RectangleUpdate<u32>),
@ -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<Self::Message> {
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<String, String> {
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("<html") || lower.contains("<!doctype")
}
/// Compute the retry delay using exponential backoff:
/// 5s, 10s, 20s, 40s, 80s, 120s, 120s, ...
fn retry_delay(retry_count: u32) -> 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
}