// SPDX-License-Identifier: MIT use cosmic::iced::{Color, Font, Limits, Rectangle, Subscription, window}; use cosmic::iced::alignment::{Horizontal, Vertical}; use cosmic::iced::platform_specific::shell::wayland::commands::popup::{destroy_popup, get_popup}; use cosmic::iced::widget::text::LineHeight; use cosmic::widget::{self, autosize, container, rectangle_tracker::{ RectangleTracker, RectangleUpdate, rectangle_tracker_subscription, }}; use cosmic::{iced_futures, prelude::*, surface}; use cosmic::applet::cosmic_panel_config::PanelAnchor; use cosmic::iced_core::Shadow; use futures_util::{SinkExt, StreamExt}; use std::time::Duration; use crate::config::Units; 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; // --------------------------------------------------------------------------- // Popup geometry // // The popup surface must have a size that is *constant* across every state it // can render (loading placeholder, loaded forecast, error message), and both // `toggle_popup` and `view_window` must agree on that size exactly. // // Violating either invariant previously killed the applet: the compositor was // told the surface could be up to 3206px wide (`toggle_popup` measured the // forecast in *bytes* via `str::len`, and the box-drawing characters are 3 // bytes each) while `view_window` laid out 1106px (measuring in *chars*). The // popup then also resized when the async fetch replaced "Loading…" with the // much wider table. Either way the surface attached a buffer before acking the // initial `configure`, and the compositor terminated us with: // // xdg_surface#83: error 3: must ack the initial configure before attaching buffer // // Everything below is therefore derived from fixed constants and never from the // text currently being displayed. // --------------------------------------------------------------------------- /// Column count of the wttr.in `?A&T` forecast table. This layout is a /// fixed-width grid, so it does not vary with the reported conditions. const FORECAST_COLUMNS: f32 = 125.0; /// Line count allotted to the forecast report. The report itself is 38 lines; /// the surplus is headroom so a slightly taller report cannot force a resize. const FORECAST_LINES: f32 = 40.0; /// Font size used for the popup's monospace report. const MONO_FONT_SIZE: f32 = 14.0; /// Line height used for the popup's monospace report. Set explicitly rather /// than left to the theme so the popup's height is fully determined by these /// constants instead of by font metrics we would have to guess at. const MONO_LINE_HEIGHT: f32 = 18.0; /// Approximate advance width of one monospace character at `MONO_FONT_SIZE`. const MONO_CHAR_WIDTH: f32 = 8.4; /// Padding between the popup's border and its text. const POPUP_PADDING: f32 = 12.0; /// 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 { /// Application state which is managed by the COSMIC runtime. core: cosmic::Core, /// Current weather data (icon|temp) weather_text: String, /// Detected location from wttr.in location: String, /// Last successful update time last_updated: String, /// Full weather report for popup full_weather: String, /// Last error from a full-weather fetch, if any. Kept separate from /// `full_weather` so that a failed fetch does not poison the cache /// and block the next popup-open from retrying. full_weather_error: Option, /// Loading state is_loading: bool, /// Popup window ID popup: Option, /// Button rectangle for popup positioning rectangle: Rectangle, /// Rectangle tracker rectangle_tracker: Option>, /// Number of consecutive fetch retries (for captive portal / network issues) retry_count: u32, /// Unit system to request from wttr.in (None = let the service geolocate). units: Option, } /// Messages emitted by the applet. #[derive(Debug, Clone)] pub enum Message { WeatherUpdate(Result<(String, String), String>), FullWeatherUpdate(Result), RefreshWeather, NetworkChanged, TogglePopup, CloseRequested(window::Id), Rectangle(RectangleUpdate), Surface(surface::Action), } impl cosmic::Application for AppModel { type Executor = cosmic::executor::Default; type Flags = Option; type Message = Message; const APP_ID: &'static str = "org.lerch.weather"; fn core(&self) -> &cosmic::Core { &self.core } fn core_mut(&mut self) -> &mut cosmic::Core { &mut self.core } fn init( core: cosmic::Core, flags: Self::Flags, ) -> (Self, Task>) { let app = AppModel { core, weather_text: "Loading...".to_string(), location: String::new(), last_updated: String::new(), full_weather: String::new(), full_weather_error: None, is_loading: true, popup: None, rectangle: Rectangle::default(), rectangle_tracker: None, retry_count: 0, units: flags, }; let units = app.units; let command = Task::perform(fetch_weather(units), |result| { cosmic::Action::App(Message::WeatherUpdate(result)) }); (app, command) } fn view(&self) -> Element<'_, Self::Message> { let button = if self.is_loading { widget::button::custom(widget::text("⏳").font(cosmic::font::bold())) .class(cosmic::theme::Button::Text) } else { let parts: Vec<&str> = self.weather_text.split('|').collect(); if parts.len() == 2 { widget::button::custom( widget::row() .push(widget::text(parts[0]).font(cosmic::font::bold())) .push(widget::text("|")) .push(widget::text(parts[1])) .spacing(0), ) .class(cosmic::theme::Button::Text) .on_press(Message::TogglePopup) } else { widget::button::custom(widget::text(&self.weather_text)) .class(cosmic::theme::Button::Text) .on_press(Message::TogglePopup) } }; let has_popup = self.popup.is_some(); let tooltip_text = if self.location.is_empty() { "Loading...".to_string() } else if self.last_updated.is_empty() { self.location.clone() } else { format!("{}\nUpdated: {}", self.location, self.last_updated) }; let tooltip = self.core.applet.applet_tooltip( button, tooltip_text, has_popup, Message::Surface, None, ); let limits = Limits::NONE.min_width(1.).min_height(1.); let element: Element<'_, Self::Message> = if let Some(tracker) = self.rectangle_tracker.as_ref() { tracker.container(0, tooltip).ignore_bounds(true).into() } else { container(tooltip).into() }; autosize::autosize( container(element).padding(4), cosmic::widget::Id::new("weather-autosize"), ) .limits(limits) .into() } fn view_window(&self, _id: window::Id) -> Element<'_, Self::Message> { // Pick what to render: the cached forecast, a transient error // (not stored in `full_weather` so the next open retries), or a // loading placeholder while the in-flight request completes. let display_text: String = if !self.full_weather.is_empty() { self.full_weather.clone() } else if let Some(err) = &self.full_weather_error { format!( "Failed to load weather:\n{err}\n\nClose and reopen to retry." ) } else { "Loading…".to_string() }; // Pad every state out to a full-height report so that the popup's // measured size does not change when the fetch resolves. let display_text = pad_to_forecast_height(&display_text); let (content_width, content_height) = popup_content_size(); let (surface_width, surface_height) = popup_surface_size(); // Fixing both axes means the content measures identically no matter // which of the three states above is being rendered. let content = container( widget::text(display_text) .font(Font::MONOSPACE) .size(MONO_FONT_SIZE) .line_height(LineHeight::Absolute(MONO_LINE_HEIGHT.into())) .wrapping(cosmic::iced::widget::text::Wrapping::None) .width(cosmic::iced::Length::Fixed(content_width)) .height(cosmic::iced::Length::Fixed(content_height)), ) .padding(POPUP_PADDING); // Build our own popup container instead of using // self.core.applet.popup_container() which hardcodes max_width(360). // See: https://github.com/pop-os/libcosmic/issues/717 let (vertical_align, horizontal_align) = match self.core.applet.anchor { PanelAnchor::Left => (Vertical::Center, Horizontal::Left), PanelAnchor::Right => (Vertical::Center, Horizontal::Right), PanelAnchor::Top => (Vertical::Top, Horizontal::Center), PanelAnchor::Bottom => (Vertical::Bottom, Horizontal::Center), }; autosize::autosize( container( container(content).class(cosmic::style::Container::custom(|theme| { let cosmic = theme.cosmic(); let corners = cosmic.corner_radii; cosmic::iced_widget::container::Style { text_color: Some(cosmic.background.on.into()), background: Some(Color::from(cosmic.background.base).into()), border: cosmic::iced::Border { radius: corners.radius_m.into(), width: 1.0, color: cosmic.background.divider.into(), }, shadow: Shadow::default(), icon_color: Some(cosmic.background.on.into()), } })) ) .width(cosmic::iced::Length::Shrink) .height(cosmic::iced::Length::Shrink) .align_x(horizontal_align) .align_y(vertical_align), cosmic::widget::Id::new("weather-popup-autosize"), ) .limits( // Must match the positioner size limits set in `toggle_popup`. Limits::NONE .min_width(1.0) .min_height(1.0) .max_width(surface_width) .max_height(surface_height), ) .into() } fn on_close_requested(&self, id: window::Id) -> Option { Some(Message::CloseRequested(id)) } 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)); interval.tick().await; // Skip first tick loop { interval.tick().await; _ = emitter.send(Message::RefreshWeather).await; } }) }), // 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"); }) }), ]) } fn update(&mut self, message: Self::Message) -> Task> { match message { Message::WeatherUpdate(result) => { self.is_loading = false; match result { Ok((location, weather)) => { 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") || e.contains("captive") || e.contains("Captive") { "\u{1f310}\u{274c}".to_string() // Network issue } else { "\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 ); let units = self.units; return Task::perform( fetch_weather_delayed(delay, units), |result| cosmic::Action::App(Message::WeatherUpdate(result)), ); } eprintln!( "Max retries ({MAX_RETRIES}) reached, waiting for next scheduled refresh" ); } } Task::none() } Message::FullWeatherUpdate(result) => { match result { Ok(weather) => { self.full_weather = weather; self.full_weather_error = None; } Err(e) => { eprintln!("Full weather fetch error: {e}"); // Leave `full_weather` empty so that the next // popup open triggers a refetch (see toggle_popup). self.full_weather_error = Some(e); } } Task::none() } Message::RefreshWeather => { self.retry_count = 0; // Invalidate the popup cache on each scheduled refresh so // that the next popup open fetches a fresh forecast. This // piggybacks the popup's freshness on the panel's 15-minute // refresh cadence (WEATHER_UPDATE_INTERVAL_MINUTES). self.full_weather.clear(); self.full_weather_error = None; let units = self.units; Task::perform(fetch_weather(units), |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(); self.full_weather_error = None; let units = self.units; Task::perform(fetch_weather(units), |result| { cosmic::Action::App(Message::WeatherUpdate(result)) }) } Message::TogglePopup => self.toggle_popup(), Message::CloseRequested(id) => { if Some(id) == self.popup { self.popup = None; } Task::none() } Message::Rectangle(u) => { match u { RectangleUpdate::Rectangle(r) => { self.rectangle = r.1; } RectangleUpdate::Init(tracker) => { self.rectangle_tracker = Some(tracker); } } Task::none() } Message::Surface(action) => { cosmic::task::message(cosmic::Action::Cosmic( cosmic::app::Action::Surface(action), )) } } } } impl AppModel { fn toggle_popup(&mut self) -> Task> { if let Some(p) = self.popup.take() { return destroy_popup(p); } let new_id = window::Id::unique(); self.popup = Some(new_id); let mut popup_settings = self.core.applet.get_popup_settings( self.core.main_window_id().unwrap(), new_id, None, None, None, ); let Rectangle { x, y, width, height } = self.rectangle; #[allow(clippy::cast_possible_truncation)] let anchor_rect = Rectangle:: { x: x.max(1.) as i32, y: y.max(1.) as i32, width: width.max(1.) as i32, height: height.max(1.) as i32, }; popup_settings.positioner.anchor_rect = anchor_rect; popup_settings.positioner.size = None; // Report the same constant geometry that `view_window` lays out. This // previously measured `self.full_weather` in bytes via `str::len()`, // which triple-counts the table's 3-byte box-drawing characters and so // disagreed with the renderer by ~2100px. let (surface_width, surface_height) = popup_surface_size(); popup_settings.positioner.size_limits = Limits::NONE .min_width(1.0) .min_height(1.0) .max_width(surface_width) .max_height(surface_height); if self.full_weather.is_empty() { let units = self.units; let fetch_task = Task::perform(fetch_full_weather(units), |result| { cosmic::Action::App(Message::FullWeatherUpdate(result)) }); Task::batch([get_popup(popup_settings), fetch_task]) } else { get_popup(popup_settings) } } } async fn fetch_weather(units: Option) -> Result<(String, String), 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}/?format=%l|%c|%t{suffix}", suffix = units_suffix(units) )) .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 { let location = parts[0].to_string(); let weather = format!("{}|{}", parts[1], parts[2]); Ok((location, weather)) } else { Ok((String::new(), text.to_string())) } } fn format_current_time() -> String { // Include %H:%M in the wttr.in request would add another field to parse; // instead, record the local time when we receive the update. // We use /proc/self or a simple UTC-based approach. Since the applet // runs locally, we can shell out or use libc. For simplicity, use // the `date` command which respects the user's timezone. std::process::Command::new("date") .arg("+%H:%M") .output() .ok() .and_then(|o| String::from_utf8(o.stdout).ok()) .map(|s| s.trim().to_string()) .unwrap_or_default() } async fn fetch_full_weather(units: Option) -> Result { 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{suffix}", suffix = units_suffix(units))) .send() .await .map_err(|e| e.to_string())?; let text = response.text().await.map_err(|e| e.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, units: Option, ) -> Result<(String, String), String> { tokio::time::sleep(Duration::from_secs(delay_secs)).await; fetch_weather(units).await } /// Build the URL suffix that selects a unit system on wttr.in. /// Returns an empty string when no explicit choice has been made. fn units_suffix(units: Option) -> String { match units { Some(u) => format!("&{}", u.query_param()), None => String::new(), } } /// Size of the popup's text area, in logical pixels. /// /// Deliberately takes no arguments: the popup is always sized for a full /// forecast table regardless of what is currently loaded. See the "Popup /// geometry" comment at the top of this module for why this must be constant. fn popup_content_size() -> (f32, f32) { ( // One column of slack so a slightly wider glyph advance than // `MONO_CHAR_WIDTH` cannot clip the table's right-hand border. (FORECAST_COLUMNS + 1.0) * MONO_CHAR_WIDTH, FORECAST_LINES * MONO_LINE_HEIGHT, ) } /// Outer size of the popup surface, including `POPUP_PADDING` on all sides. /// /// This is the single value both `toggle_popup` (which reports it to the /// compositor as the positioner's size limits) and `view_window` (which uses it /// as the autosize limits) must agree on. fn popup_surface_size() -> (f32, f32) { let (width, height) = popup_content_size(); ( width + POPUP_PADDING * 2.0, height + POPUP_PADDING * 2.0, ) } /// Pad `text` to exactly `FORECAST_LINES` lines. /// /// This keeps the "Loading…" placeholder and any error message occupying the /// same vertical space as a fully loaded forecast, so the popup does not resize /// when the in-flight request resolves. Longer text is left intact; the fixed /// height of the content container clips it rather than growing the surface. fn pad_to_forecast_height(text: &str) -> String { #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] let target = FORECAST_LINES as usize; let mut padded = text.to_string(); for _ in text.lines().count()..target { padded.push('\n'); } padded }