update on network change
This commit is contained in:
parent
2153f7b52b
commit
07cce8ec0d
3 changed files with 160 additions and 7 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1169,6 +1169,7 @@ dependencies = [
|
|||
"reqwest",
|
||||
"rust-embed",
|
||||
"tokio",
|
||||
"zbus 5.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
163
src/app.rs
163
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<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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue