diff --git a/Justfile b/Justfile index 50198bf..c8663d4 100644 --- a/Justfile +++ b/Justfile @@ -17,7 +17,7 @@ build: # Run a named test-support smoke screen. [group('dev')] smoke screen: - @case '{{ screen }}' in recovery|manual-browser|browser-waiting|feed-error|feed-loading|feed-live|feed-degraded|alerts-gallery|button-gallery|skeleton-gallery|tabs-gallery|timeline-gallery|video-gallery|video-gallery-large|settings|settings-saving|settings-error) ;; *) echo "unknown smoke screen: {{ screen }}" >&2; exit 2 ;; esac + @case '{{ screen }}' in recovery|manual-browser|browser-waiting|feed-error|feed-loading|feed-live|feed-degraded|service-status-partial|alerts-gallery|button-gallery|skeleton-gallery|tabs-gallery|timeline-gallery|video-gallery|video-gallery-large|settings|settings-saving|settings-error) ;; *) echo "unknown smoke screen: {{ screen }}" >&2; exit 2 ;; esac HITOMI_SMOKE='{{ screen }}' cargo run --bin hitomi --features test-support # Continuously run a Bacon job; defaults to check. @@ -55,6 +55,11 @@ test-integration target *args='': jetstream-qualification: cargo test --features test-support --test jetstream_provider_qualification -- --ignored --exact production_provider_accepts_the_bounded_v2_subscription +# Explicit anonymous live check for the bounded Bluesky status-page provider. +[group('quality')] +service-status-qualification: + cargo test --features test-support --test service_status_provider_qualification -- --ignored --exact production_provider_contract_maps_a_bounded_known_health + # Run one integration target with cargo-nextest. [group('quality')] test-nextest-integration target *args='': diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index 2e6f596..31175af 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -157,6 +157,15 @@ home-live-refresh-source-unavailable-message = Hitomi will check for new posts e home-live-refresh-targets-unavailable-message = Hitomi couldn’t prepare live refresh. It will check for new posts every minute while it retries. home-live-refresh-connection-unavailable-message = Hitomi couldn’t connect to live refresh. It will check for new posts every minute while it retries. home-live-refresh-interrupted-message = Live refresh was interrupted. Hitomi will check for new posts every minute while it reconnects. +home-service-status-advisory-title = Bluesky is investigating an issue +home-service-status-advisory-message = Some Bluesky features may be affected. +home-service-status-partial-title = Bluesky is experiencing a partial outage +home-service-status-partial-message = Some Bluesky features may be unavailable or delayed. +home-service-status-major-title = Bluesky is experiencing a major outage +home-service-status-major-message = Bluesky may be temporarily unavailable. +home-service-status-unavailable-title = Bluesky status is unavailable +home-service-status-unavailable-message = Hitomi can’t currently verify Bluesky’s service status. +home-service-status-link = https://status.bsky.app home-loading-title = Loading home feed home-loading-message = Fetching the latest posts from your timeline. home-session-expired-title = Session expired diff --git a/src/infrastructure/browser.rs b/src/infrastructure/browser.rs index de3d6b2..86950c5 100644 --- a/src/infrastructure/browser.rs +++ b/src/infrastructure/browser.rs @@ -9,6 +9,14 @@ use crate::authentication::{ pub struct SystemBrowser; impl SystemBrowser { + /// Opens the fixed public Bluesky service-status page. + pub(crate) async fn launch_service_status(self) -> Result<(), ()> { + tokio::task::spawn_blocking(|| open::that("https://status.bsky.app/")) + .await + .map_err(|_| ())? + .map_err(|_| ()) + } + pub(crate) async fn launch_authorization( &self, url: ManualAuthorizationUrl, diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index 78a51a1..811576b 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -16,6 +16,10 @@ pub mod notification; pub mod oauth; pub(crate) mod private_file; pub(crate) mod profile; +#[cfg(feature = "test-support")] +pub mod service_status; +#[cfg(not(feature = "test-support"))] +pub(crate) mod service_status; pub mod session_vault; pub(crate) mod settings; pub mod transport; diff --git a/src/infrastructure/service_status.rs b/src/infrastructure/service_status.rs new file mode 100644 index 0000000..09e72bd --- /dev/null +++ b/src/infrastructure/service_status.rs @@ -0,0 +1,418 @@ +//! Bounded anonymous adapter for the Bluesky status-page provider. + +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use http::{HeaderMap, Method, StatusCode}; +use serde::Deserialize; +use tokio::sync::RwLock; +use url::Url; + +use crate::infrastructure::transport::{ + HardenedHttpClient, OutboundHttp, OutboundRequest, OutboundResponse, RemoteHttpsUrl, + RequestTimeout, +}; +use crate::service_status::{ + BlueskyServiceHealth, BlueskyServiceStatus, ServiceStatusFuture, ServiceStatusReadError, + ServiceStatusReader, +}; + +const ROOT: &str = "https://status.bsky.app/"; +const ROOT_MAX_BYTES: usize = 64 * 1024; +const MONITORS_MAX_BYTES: usize = 16 * 1024; +const EVENTS_MAX_BYTES: usize = 64 * 1024; +const HOST: &str = "status.bsky.app"; + +#[derive(Clone)] +struct DiscoveredEndpoints { + monitors: RemoteHttpsUrl, + events: RemoteHttpsUrl, +} + +/// Cloneable reader for the fixed Bluesky status page. +#[derive(Clone)] +pub(crate) struct BlueskyStatusReader { + client: C, + endpoints: Arc>>, +} + +impl BlueskyStatusReader +where + C: OutboundHttp, +{ + pub(crate) fn new(client: C) -> Self { + Self { + client, + endpoints: Arc::new(RwLock::new(None)), + } + } + + async fn read_status(&self) -> Result { + let endpoints = self.endpoints().await?; + match self.read_endpoints(&endpoints).await { + Ok(status) => Ok(status), + Err(ReadAttemptError::Rediscover) => { + *self.endpoints.write().await = None; + let endpoints = self.endpoints().await?; + self.read_endpoints(&endpoints) + .await + .map_err(|_| ServiceStatusReadError::Unavailable) + } + Err(ReadAttemptError::Unavailable) => Err(ServiceStatusReadError::Unavailable), + } + } + + async fn endpoints(&self) -> Result { + if let Some(endpoints) = self.endpoints.read().await.clone() { + return Ok(endpoints); + } + let discovered = self.discover().await?; + let mut cache = self.endpoints.write().await; + Ok(cache.get_or_insert(discovered).clone()) + } + + async fn discover(&self) -> Result { + let root = RemoteHttpsUrl::parse(ROOT).map_err(|_| ServiceStatusReadError::Unavailable)?; + let response = self + .send(root, ROOT_MAX_BYTES) + .await + .map_err(|_| ServiceStatusReadError::Unavailable)?; + if response.status != StatusCode::OK || !is_html(&response) { + return Err(ServiceStatusReadError::Unavailable); + } + let page = + std::str::from_utf8(&response.body).map_err(|_| ServiceStatusReadError::Unavailable)?; + let monitors = + validated_discovery_value(page, "window.pspApiPath", "/api/getMonitorList/")?; + let events = validated_discovery_value(page, "window.eventsApiPath", "/api/getEventFeed/")?; + Ok(DiscoveredEndpoints { monitors, events }) + } + + async fn read_endpoints( + &self, + endpoints: &DiscoveredEndpoints, + ) -> Result { + let monitor_url = with_query(&endpoints.monitors, "page", "999999")?; + let from_date = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| ReadAttemptError::Unavailable)? + .as_secs() + .checked_sub(30 * 24 * 60 * 60) + .ok_or(ReadAttemptError::Unavailable)? + .to_string(); + let event_url = with_query(&endpoints.events, "from_date", &from_date)?; + let (monitor_response, event_response) = tokio::join!( + self.send(monitor_url, MONITORS_MAX_BYTES), + self.send(event_url, EVENTS_MAX_BYTES) + ); + let monitor_response = monitor_response.map_err(|_| ReadAttemptError::Unavailable)?; + let event_response = event_response.map_err(|_| ReadAttemptError::Unavailable)?; + if matches!( + monitor_response.status, + StatusCode::NOT_FOUND | StatusCode::GONE + ) || matches!( + event_response.status, + StatusCode::NOT_FOUND | StatusCode::GONE + ) { + return Err(ReadAttemptError::Rediscover); + } + let monitors = decode_monitors(&monitor_response).map_err(|invalid| { + if invalid { + ReadAttemptError::Rediscover + } else { + ReadAttemptError::Unavailable + } + })?; + let events = decode_events(&event_response).map_err(|invalid| { + if invalid { + ReadAttemptError::Rediscover + } else { + ReadAttemptError::Unavailable + } + })?; + Ok(BlueskyServiceStatus::new(classify(monitors, events))) + } + + async fn send( + &self, + url: RemoteHttpsUrl, + max_response_bytes: usize, + ) -> Result { + let request = OutboundRequest::new( + Method::GET, + url, + HeaderMap::new(), + Vec::new(), + max_response_bytes, + RequestTimeout::STANDARD, + )?; + self.client.send(request).await + } +} + +impl BlueskyStatusReader { + pub(crate) fn system() -> Result { + HardenedHttpClient::new_get_only() + .map(Self::new) + .map_err(|_| ServiceStatusReadError::Unavailable) + } +} + +impl ServiceStatusReader for BlueskyStatusReader +where + C: OutboundHttp, +{ + fn read(&self) -> ServiceStatusFuture { + let reader = self.clone(); + Box::pin(async move { reader.read_status().await }) + } +} + +#[derive(Clone, Copy)] +enum ReadAttemptError { + Unavailable, + Rediscover, +} + +#[derive(Deserialize)] +struct MonitorEnvelope { + status: String, + statistics: MonitorStatistics, +} + +#[derive(Deserialize)] +struct MonitorStatistics { + counts: MonitorCounts, +} + +#[derive(Deserialize)] +struct MonitorCounts { + up: u64, + down: u64, + paused: u64, + total: u64, +} + +#[derive(Deserialize)] +struct EventEnvelope { + status: bool, + #[serde(default, rename = "results")] + events: Vec, +} + +#[derive(Deserialize)] +struct Event { + #[serde(rename = "eventType")] + event_type: String, + #[serde(rename = "endDate")] + end_date: Option, +} + +fn is_html(response: &OutboundResponse) -> bool { + response + .headers + .get(http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .split(';') + .next() + .is_some_and(|type_| type_.eq_ignore_ascii_case("text/html")) + }) +} + +fn is_json(response: &OutboundResponse) -> bool { + response + .headers + .get(http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .split(';') + .next() + .is_some_and(|type_| type_.eq_ignore_ascii_case("application/json")) + }) +} + +fn validated_discovery_value( + page: &str, + assignment: &str, + required_prefix: &str, +) -> Result { + let (_, tail) = page + .split_once(assignment) + .ok_or(ServiceStatusReadError::Unavailable)?; + let (_, tail) = tail + .split_once('=') + .ok_or(ServiceStatusReadError::Unavailable)?; + let tail = tail.trim_start(); + let quote = tail + .chars() + .next() + .filter(|quote| *quote == '\'' || *quote == '"') + .ok_or(ServiceStatusReadError::Unavailable)?; + let value = tail[quote.len_utf8()..] + .split_once(quote) + .map(|(value, _)| value) + .ok_or(ServiceStatusReadError::Unavailable)?; + let url = RemoteHttpsUrl::parse(value).map_err(|_| ServiceStatusReadError::Unavailable)?; + let parsed = url.as_url(); + if parsed.scheme() != "https" + || parsed.host_str() != Some(HOST) + || parsed.port().is_some() + || parsed.username() != "" + || parsed.password().is_some() + || parsed.fragment().is_some() + || parsed.query().is_some() + || !parsed.path().starts_with(required_prefix) + { + return Err(ServiceStatusReadError::Unavailable); + } + Ok(url) +} + +fn with_query( + endpoint: &RemoteHttpsUrl, + name: &str, + value: &str, +) -> Result { + let mut url: Url = endpoint.as_url().clone(); + url.query_pairs_mut().append_pair(name, value); + RemoteHttpsUrl::parse(url.as_str()).map_err(|_| ReadAttemptError::Unavailable) +} + +fn decode_monitors(response: &OutboundResponse) -> Result { + if response.status != StatusCode::OK || !is_json(response) { + return Err(false); + } + let envelope: MonitorEnvelope = serde_json::from_slice(&response.body).map_err(|_| true)?; + if envelope.status != "ok" + || envelope.statistics.counts.total == 0 + || envelope + .statistics + .counts + .up + .checked_add(envelope.statistics.counts.down) + .and_then(|count| count.checked_add(envelope.statistics.counts.paused)) + != Some(envelope.statistics.counts.total) + { + return Err(true); + } + Ok(envelope.statistics.counts) +} + +fn decode_events(response: &OutboundResponse) -> Result { + if response.status != StatusCode::OK || !is_json(response) { + return Err(false); + } + let envelope: EventEnvelope = serde_json::from_slice(&response.body).map_err(|_| true)?; + if !envelope.status { + return Err(true); + } + let mut active_announcement = false; + for event in envelope.events { + if event.event_type == "announcement" { + match event.end_date { + Some(serde_json::Value::Null) => active_announcement = true, + Some(serde_json::Value::String(_)) => {} + Some(_) | None => return Err(true), + } + } + } + Ok(active_announcement) +} + +fn classify(counts: MonitorCounts, active_announcement: bool) -> BlueskyServiceHealth { + if counts.paused == counts.total { + BlueskyServiceHealth::MonitoringUnavailable + } else if counts.down == 0 && !active_announcement { + BlueskyServiceHealth::Operational + } else if counts.down == 0 { + BlueskyServiceHealth::Advisory + } else if counts.up == 0 && counts.paused == 0 { + BlueskyServiceHealth::MajorOutage + } else { + BlueskyServiceHealth::PartialOutage + } +} + +#[cfg(feature = "test-support")] +pub async fn qualify_live() -> Result { + BlueskyStatusReader::system()? + .read() + .await + .map(|status| status.health()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::infrastructure::transport::test_support::response; + use http::Response; + + fn counts(up: u64, down: u64, paused: u64) -> MonitorCounts { + MonitorCounts { + up, + down, + paused, + total: up + down + paused, + } + } + + #[test] + fn maps_each_valid_provider_health() { + assert_eq!( + classify(counts(84, 0, 0), false), + BlueskyServiceHealth::Operational + ); + assert_eq!( + classify(counts(84, 0, 0), true), + BlueskyServiceHealth::Advisory + ); + assert_eq!( + classify(counts(83, 1, 0), true), + BlueskyServiceHealth::PartialOutage + ); + assert_eq!( + classify(counts(0, 84, 0), false), + BlueskyServiceHealth::MajorOutage + ); + assert_eq!( + classify(counts(0, 0, 84), false), + BlueskyServiceHealth::MonitoringUnavailable + ); + } + + #[test] + fn decodes_the_provider_monitor_envelope() { + let response = response( + Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .body( + br#"{"status":"ok","data":[],"statistics":{"counts":{"up":84,"down":1,"paused":0,"total":85}}}"# + .to_vec(), + ) + .expect("static response"), + ); + + let counts = decode_monitors(&response).expect("provider envelope"); + assert_eq!(counts.up, 84); + assert_eq!(counts.down, 1); + } + + #[test] + fn rejects_other_hosts_ports_and_paths_during_discovery() { + for value in [ + "https://status.bsky.app:444/api/getMonitorList/x", + "https://example.com/api/getMonitorList/x", + "https://status.bsky.app/nope", + ] { + let page = format!("window.pspApiPath = '{value}'"); + assert!( + validated_discovery_value(&page, "window.pspApiPath", "/api/getMonitorList/") + .is_err() + ); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 11a73f8..0c709d3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod feed; pub mod infrastructure; pub mod media; pub mod profile; +pub mod service_status; pub mod notification; mod release_identity; diff --git a/src/service_status.rs b/src/service_status.rs new file mode 100644 index 0000000..287fa9e --- /dev/null +++ b/src/service_status.rs @@ -0,0 +1,142 @@ +use std::future::Future; +use std::pin::Pin; + +/// Provider-confirmed aggregate health for Bluesky services. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BlueskyServiceHealth { + Operational, + Advisory, + PartialOutage, + MajorOutage, + MonitoringUnavailable, +} + +/// Provider-neutral service-status observation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BlueskyServiceStatus { + health: BlueskyServiceHealth, +} + +impl BlueskyServiceStatus { + pub const fn new(health: BlueskyServiceHealth) -> Self { + Self { health } + } + + pub const fn health(&self) -> BlueskyServiceHealth { + self.health + } +} + +/// Sanitized outcome for an unreadable or untrustworthy provider observation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ServiceStatusReadError { + Unavailable, +} + +pub type ServiceStatusFuture = Pin< + Box> + Send + 'static>, +>; + +/// Application-owned boundary for reading provider health. +pub trait ServiceStatusReader: Send + Sync { + fn read(&self) -> ServiceStatusFuture; +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ServiceStatusState { + Checking, + Operational, + Advisory, + PartialOutage, + MajorOutage, + MonitoringUnavailable, +} + +#[derive(Debug)] +pub struct ServiceStatusTracker { + state: ServiceStatusState, + consecutive_failures: u8, +} + +impl Default for ServiceStatusTracker { + fn default() -> Self { + Self { + state: ServiceStatusState::Checking, + consecutive_failures: 0, + } + } +} + +impl ServiceStatusTracker { + pub const fn state(&self) -> ServiceStatusState { + self.state + } + + pub fn record_success(&mut self, status: BlueskyServiceStatus) { + self.state = match status.health() { + BlueskyServiceHealth::Operational => ServiceStatusState::Operational, + BlueskyServiceHealth::Advisory => ServiceStatusState::Advisory, + BlueskyServiceHealth::PartialOutage => ServiceStatusState::PartialOutage, + BlueskyServiceHealth::MajorOutage => ServiceStatusState::MajorOutage, + BlueskyServiceHealth::MonitoringUnavailable => { + ServiceStatusState::MonitoringUnavailable + } + }; + self.consecutive_failures = 0; + } + + pub fn record_failure(&mut self) { + self.consecutive_failures = self.consecutive_failures.saturating_add(1); + if self.consecutive_failures >= 3 { + self.state = ServiceStatusState::MonitoringUnavailable; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn given_a_valid_operational_observation_no_banner_state_remains() { + let mut tracker = ServiceStatusTracker::default(); + tracker.record_success(BlueskyServiceStatus::new(BlueskyServiceHealth::Operational)); + assert_eq!(tracker.state(), ServiceStatusState::Operational); + } + + #[test] + fn given_an_active_announcement_with_no_down_monitors_state_is_advisory() { + let mut tracker = ServiceStatusTracker::default(); + tracker.record_success(BlueskyServiceStatus::new(BlueskyServiceHealth::Advisory)); + assert_eq!(tracker.state(), ServiceStatusState::Advisory); + } + + #[test] + fn given_some_monitors_down_state_is_partial_outage() { + let mut tracker = ServiceStatusTracker::default(); + tracker.record_success(BlueskyServiceStatus::new( + BlueskyServiceHealth::PartialOutage, + )); + assert_eq!(tracker.state(), ServiceStatusState::PartialOutage); + } + + #[test] + fn given_every_monitored_service_down_state_is_major_outage() { + let mut tracker = ServiceStatusTracker::default(); + tracker.record_success(BlueskyServiceStatus::new(BlueskyServiceHealth::MajorOutage)); + assert_eq!(tracker.state(), ServiceStatusState::MajorOutage); + } + + #[test] + fn failures_preserve_confirmed_state_until_the_third_and_later_success_recovers() { + let mut tracker = ServiceStatusTracker::default(); + tracker.record_success(BlueskyServiceStatus::new(BlueskyServiceHealth::Advisory)); + tracker.record_failure(); + tracker.record_failure(); + assert_eq!(tracker.state(), ServiceStatusState::Advisory); + tracker.record_failure(); + assert_eq!(tracker.state(), ServiceStatusState::MonitoringUnavailable); + tracker.record_success(BlueskyServiceStatus::new(BlueskyServiceHealth::Operational)); + assert_eq!(tracker.state(), ServiceStatusState::Operational); + } +} diff --git a/src/ui.rs b/src/ui.rs index 4e48ad1..3d0fe9e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -87,6 +87,9 @@ fn smoke_screen_for(value: Option<&str>) -> Option { Some("feed-loading") => Some(Screen::Home(Box::new(home::smoke_loading()))), Some("feed-live") => Some(Screen::Home(Box::new(home::smoke_stream_live()))), Some("feed-degraded") => Some(Screen::Home(Box::new(home::smoke_stream_degraded()))), + Some("service-status-partial") => { + Some(Screen::Home(Box::new(home::smoke_service_status_partial()))) + } Some("alerts-gallery") => Some(Screen::AlertsGallery), Some("button-gallery") => Some(Screen::ButtonGallery), Some("skeleton-gallery") => Some(Screen::SkeletonGallery), @@ -1015,6 +1018,8 @@ mod tests { media_supervisor: None, feed_stream_supervisor: None, feed_stream: Err(crate::feed::HomeFeedStreamDegradedReason::SourceUnavailable), + service_status_reader: None, + next_home_instance_generation: 0, main_window: iced::window::Id::unique(), main_window_frame: None, main_window_save_deadline: None, @@ -2006,6 +2011,10 @@ mod tests { smoke_screen_for(Some("feed-loading")), Some(Screen::Home(state)) if home::needs_spinner_ticks(&state) )); + assert!(matches!( + smoke_screen_for(Some("service-status-partial")), + Some(Screen::Home(_)) + )); assert!(matches!( smoke_screen_for(Some("browser-waiting")), Some(Screen::Login(state)) if login::needs_spinner_ticks(&state) diff --git a/src/ui/app.rs b/src/ui/app.rs index 37ec8bb..af4e455 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -77,6 +77,8 @@ pub(crate) struct Hitomi { Arc, crate::feed::HomeFeedStreamDegradedReason, >, + pub(super) service_status_reader: Option>, + pub(super) next_home_instance_generation: u64, pub(super) main_window: iced::window::Id, pub(super) sidebar_material: SidebarMaterialState, pub(super) main_window_frame: Option, @@ -322,6 +324,16 @@ pub(crate) fn boot_with_settings( }) .map_err(|_| crate::feed::HomeFeedStreamDegradedReason::SourceUnavailable); #[cfg(feature = "test-support")] + let service_status_reader = matches!(mode, SmokeMode::Normal) + .then(crate::infrastructure::service_status::BlueskyStatusReader::system) + .and_then(Result::ok) + .map(|reader| Arc::new(reader) as Arc); + #[cfg(not(feature = "test-support"))] + let service_status_reader = + crate::infrastructure::service_status::BlueskyStatusReader::system() + .ok() + .map(|reader| Arc::new(reader) as Arc); + #[cfg(feature = "test-support")] let screen = super::smoke_screen().unwrap_or(Screen::RestoringSession); #[cfg(not(feature = "test-support"))] let screen = Screen::RestoringSession; @@ -362,6 +374,8 @@ pub(crate) fn boot_with_settings( media_supervisor: None, feed_stream_supervisor: None, feed_stream, + service_status_reader, + next_home_instance_generation: 0, main_window, sidebar_material: SidebarMaterialState::NotStarted, main_window_frame, @@ -978,12 +992,18 @@ pub(crate) fn update(state: &mut Hitomi, message: Message) -> Task { let autoplay_videos = state.settings.applied().autoplay_videos(); let stream_source_failure = state.feed_stream.as_ref().err().copied(); let feed_stream_supervisor = state.feed_stream_supervisor.clone(); + let service_status_reader = state.service_status_reader.clone(); + let home_instance_generation = state.next_home_instance_generation; + state.next_home_instance_generation = + state.next_home_instance_generation.wrapping_add(1); let task = state.replace_screen_with_task(|scope| { let (mut home, task) = home::enter_scoped( authentication, supervisor, refresh_mode, stream_source_failure, + service_status_reader, + home_instance_generation, scope.child_scope(), ); home::set_video_autoplay(&mut home, autoplay_videos); @@ -1153,12 +1173,18 @@ pub(crate) fn update(state: &mut Hitomi, message: Message) -> Task { let autoplay_videos = state.settings.applied().autoplay_videos(); let stream_source_failure = state.feed_stream.as_ref().err().copied(); let feed_stream_supervisor = state.feed_stream_supervisor.clone(); + let service_status_reader = state.service_status_reader.clone(); + let home_instance_generation = state.next_home_instance_generation; + state.next_home_instance_generation = + state.next_home_instance_generation.wrapping_add(1); let task = state.replace_screen_with_task(|scope| { let (mut home, task) = home::enter_scoped( authentication, supervisor, refresh_mode, stream_source_failure, + service_status_reader, + home_instance_generation, scope.child_scope(), ); home::set_video_autoplay(&mut home, autoplay_videos); diff --git a/src/ui/home.rs b/src/ui/home.rs index a035eed..be3f68b 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use iced::widget::operation; -use iced::{Task, clipboard}; +use iced::{Subscription, Task, clipboard}; use super::{ authentication_diagnostic, @@ -18,12 +18,6 @@ pub(crate) use view::ViewOptions; pub(crate) use view::view; pub(crate) mod media_loader; -use feed_lifecycle::{FeedLifecycle, Phase}; -use media_loader::{ - FetchJob, FetchJobStatus, FetchMode, INITIAL_RETRY_DELAY, MediaLoader, MediaRequestState, - STALE_RETRY_DELAY, -}; - use crate::application::DesktopAuthentication; use crate::authentication::{AuthenticationError, AuthenticationErrorCategory, LogoutReport}; use crate::feed::{ @@ -33,12 +27,24 @@ use crate::feed::{ use crate::media::{ AVATAR_CACHE_FRESHNESS, AvatarCache, AvatarCacheState, MediaBytes, MediaKind, MediaLoadError, }; +use crate::service_status::{ + BlueskyServiceStatus, ServiceStatusReadError, ServiceStatusReader, ServiceStatusTracker, +}; +use feed_lifecycle::{FeedLifecycle, Phase}; +use media_loader::{ + FetchJob, FetchJobStatus, FetchMode, INITIAL_RETRY_DELAY, MediaLoader, MediaRequestState, + STALE_RETRY_DELAY, +}; const REFRESHING_SESSION_DIAGNOSTIC: &str = "Hitomi alert\nSeverity: Warning\nTitle: Session expired\nMessage: Your saved session expired. Hitomi is securely refreshing it now."; const STREAM_RECONCILIATION_INTERVAL: Duration = Duration::from_secs(60); -#[derive(Debug)] pub(super) struct State { + service_status_reader: Option>, + service_status: ServiceStatusTracker, + home_instance_generation: u64, + next_service_status_request: u64, + active_service_status_request: Option, lifecycle: FeedLifecycle, media: MediaLoader, video: crate::video::AutoplayCoordinator, @@ -113,7 +119,15 @@ pub(crate) enum Message { Retry, CopyDiagnostic(String), RelativeTimeTick(i64), + ServiceStatusTick, + ServiceStatusFinished { + home_instance_generation: u64, + request_id: u64, + result: Result, + }, Timeline(super::timeline::Message), + OpenServiceStatus, + ServiceStatusPageOpened, } impl From for Message { @@ -218,6 +232,8 @@ pub(super) fn enter( supervisor, refresh_mode, None, + None, + 0, super::task_lifecycle::ApplicationTasks::new().new_scope(), ) } @@ -227,6 +243,8 @@ pub(super) fn enter_scoped( supervisor: Option, refresh_mode: HomeFeedRefreshMode, stream_source_failure: Option, + service_status_reader: Option>, + home_instance_generation: u64, scope: super::task_lifecycle::TaskScope, ) -> (State, Task) { let (profile, profile_task) = @@ -236,19 +254,24 @@ pub(super) fn enter_scoped( supervisor, refresh_mode, stream_source_failure, + service_status_reader, + home_instance_generation, scope, ); state.profile = Some(profile); + let status_task = start_service_status_check(&mut state); let task = if matches!(refresh_mode, HomeFeedRefreshMode::Jetstream) { Task::batch([ load_task(Arc::clone(&authentication), &state.scope, 0).map(Message::Lifecycle), restart_stream_target_discovery(&mut state, authentication), profile_task.map(Message::Profile), + status_task, ]) } else { Task::batch([ load_task(authentication, &state.scope, 0).map(Message::Lifecycle), profile_task.map(Message::Profile), + status_task, ]) }; (state, task) @@ -266,6 +289,8 @@ impl State { supervisor, refresh_mode, None, + None, + 0, super::task_lifecycle::ApplicationTasks::new().new_scope(), ) } @@ -275,9 +300,16 @@ impl State { supervisor: Option, refresh_mode: HomeFeedRefreshMode, stream_source_failure: Option, + service_status_reader: Option>, + home_instance_generation: u64, scope: super::task_lifecycle::TaskScope, ) -> Self { Self { + service_status_reader, + service_status: ServiceStatusTracker::default(), + home_instance_generation, + next_service_status_request: 0, + active_service_status_request: None, lifecycle: FeedLifecycle::new_with_scope( phase, refresh_mode, @@ -308,9 +340,29 @@ impl State { self.lifecycle.cancel_stream_target_discovery(); } } +fn start_service_status_check(state: &mut State) -> Task { + let Some(reader) = state.service_status_reader.clone() else { + return Task::none(); + }; + if state.active_service_status_request.is_some() { + return Task::none(); + } + let request_id = state.next_service_status_request; + state.next_service_status_request = state.next_service_status_request.wrapping_add(1); + state.active_service_status_request = Some(request_id); + let home_instance_generation = state.home_instance_generation; + state.scope.cancellable_task(reader.read(), move |result| { + Message::ServiceStatusFinished { + home_instance_generation, + request_id, + result, + } + }) +} /// Stops cancellable Home work before the shell terminates the runtime. pub(super) fn prepare_for_shutdown(state: &mut State) { + state.scope.cancel(); state.lifecycle.cancel_stream_target_discovery(); state.media.cancel_scope(); state.media.clear_work(state.lifecycle.generation); @@ -680,6 +732,17 @@ pub(super) fn smoke_stream_degraded() -> State { state } +#[cfg(feature = "test-support")] +pub(super) fn smoke_service_status_partial() -> State { + let mut state = smoke_timeline(); + state + .service_status + .record_success(BlueskyServiceStatus::new( + crate::service_status::BlueskyServiceHealth::PartialOutage, + )); + state +} + pub(super) fn update( state: &mut State, authentication: Arc, @@ -687,6 +750,32 @@ pub(super) fn update( message: Message, ) -> (Transition, Task) { match message { + Message::ServiceStatusTick => (Transition::Stay, start_service_status_check(state)), + Message::ServiceStatusFinished { + home_instance_generation, + request_id, + result, + } if home_instance_generation == state.home_instance_generation + && state.active_service_status_request == Some(request_id) => + { + state.active_service_status_request = None; + match result { + Ok(status) => state.service_status.record_success(status), + Err(_) => state.service_status.record_failure(), + } + (Transition::Stay, Task::none()) + } + Message::ServiceStatusFinished { .. } => (Transition::Stay, Task::none()), + Message::OpenServiceStatus => { + let browser = crate::infrastructure::browser::SystemBrowser; + ( + Transition::Stay, + Task::perform(browser.launch_service_status(), |_| { + Message::ServiceStatusPageOpened + }), + ) + } + Message::ServiceStatusPageOpened => (Transition::Stay, Task::none()), Message::Lifecycle(message) => { if matches!(&message, feed_lifecycle::Message::SignOut) && !matches!(state.lifecycle.phase, Phase::SigningOut) @@ -1481,25 +1570,32 @@ pub(super) fn manual_refresh_available(state: &State) -> bool { fn refreshable_phase(state: &State) -> bool { matches!(state.lifecycle.phase, Phase::Ready(_) | Phase::Empty) } -pub(super) fn subscription(state: &State) -> iced::Subscription { - if !refreshable_phase(state) { - return iced::Subscription::none(); - } - match state.lifecycle.refresh_mode { - HomeFeedRefreshMode::Disabled => iced::Subscription::none(), - HomeFeedRefreshMode::Every15Seconds - | HomeFeedRefreshMode::Every30Seconds - | HomeFeedRefreshMode::EveryMinute => iced::time::every( - state - .lifecycle - .refresh_mode - .interval() - .expect("timer modes have an interval"), - ) - .map(|_| automatic_refresh_message(RefreshTrigger::Interval)), - HomeFeedRefreshMode::Jetstream => iced::time::every(STREAM_RECONCILIATION_INTERVAL) - .map(|_| automatic_refresh_message(RefreshTrigger::Reconciliation)), - } +pub(super) fn subscription(state: &State) -> Subscription { + let feed_refresh = if !refreshable_phase(state) { + Subscription::none() + } else { + match state.lifecycle.refresh_mode { + HomeFeedRefreshMode::Disabled => Subscription::none(), + HomeFeedRefreshMode::Every15Seconds + | HomeFeedRefreshMode::Every30Seconds + | HomeFeedRefreshMode::EveryMinute => iced::time::every( + state + .lifecycle + .refresh_mode + .interval() + .expect("timer modes have an interval"), + ) + .map(|_| automatic_refresh_message(RefreshTrigger::Interval)), + HomeFeedRefreshMode::Jetstream => iced::time::every(STREAM_RECONCILIATION_INTERVAL) + .map(|_| automatic_refresh_message(RefreshTrigger::Reconciliation)), + } + }; + let service_status = if state.service_status_reader.is_some() { + iced::time::every(Duration::from_secs(300)).map(|_| Message::ServiceStatusTick) + } else { + Subscription::none() + }; + Subscription::batch([feed_refresh, service_status]) } fn automatic_refresh_message(trigger: RefreshTrigger) -> Message { @@ -1740,12 +1836,86 @@ mod tests { use std::time::Duration; use tokio_util::sync::CancellationToken; + #[derive(Clone)] + struct StatusReader; + + impl crate::service_status::ServiceStatusReader for StatusReader { + fn read(&self) -> crate::service_status::ServiceStatusFuture { + Box::pin(async { + Ok(crate::service_status::BlueskyServiceStatus::new( + crate::service_status::BlueskyServiceHealth::Operational, + )) + }) + } + } + fn test_state(generation: u64, phase: Phase) -> State { let mut state = State::new(phase, None, HomeFeedRefreshMode::EveryMinute); state.lifecycle.generation = generation; state } + #[test] + fn status_completion_updates_only_the_current_home_request() { + let mut state = State::new_with_scope( + Phase::Ready(HomeFeed::default()), + None, + HomeFeedRefreshMode::EveryMinute, + None, + Some(Arc::new(StatusReader)), + 7, + super::super::task_lifecycle::ApplicationTasks::new().new_scope(), + ); + let task = super::start_service_status_check(&mut state); + assert_eq!(task.units(), 1); + let request_id = state + .active_service_status_request + .expect("current request"); + let (_, task) = update( + &mut state, + Arc::default(), + None, + Message::ServiceStatusFinished { + home_instance_generation: 7, + request_id, + result: Ok(crate::service_status::BlueskyServiceStatus::new( + crate::service_status::BlueskyServiceHealth::PartialOutage, + )), + }, + ); + assert_eq!(task.units(), 0); + assert_eq!( + state.service_status.state(), + crate::service_status::ServiceStatusState::PartialOutage + ); + assert!(state.active_service_status_request.is_none()); + } + + #[test] + fn stale_status_completion_cannot_mutate_a_replaced_home() { + let mut state = test_state(0, Phase::Ready(HomeFeed::default())); + state.home_instance_generation = 2; + state.active_service_status_request = Some(4); + let (_, task) = update( + &mut state, + Arc::default(), + None, + Message::ServiceStatusFinished { + home_instance_generation: 1, + request_id: 4, + result: Ok(crate::service_status::BlueskyServiceStatus::new( + crate::service_status::BlueskyServiceHealth::MajorOutage, + )), + }, + ); + assert_eq!(task.units(), 0); + assert_eq!( + state.service_status.state(), + crate::service_status::ServiceStatusState::Checking + ); + assert_eq!(state.active_service_status_request, Some(4)); + } + #[test] fn parent_screen_scope_cancels_home_work_after_the_shell_releases_it() { let tasks = super::super::task_lifecycle::ApplicationTasks::new(); @@ -1755,6 +1925,8 @@ mod tests { None, HomeFeedRefreshMode::EveryMinute, None, + None, + 0, parent_scope.child_scope(), ); let home_work = state.scope.child_token(); diff --git a/src/ui/home/view.rs b/src/ui/home/view.rs index da27a1a..0053906 100644 --- a/src/ui/home/view.rs +++ b/src/ui/home/view.rs @@ -8,6 +8,65 @@ use iced::{Alignment, Element, Length}; use super::super::localization::{Localizer, Text}; use super::super::{Alert, AlertKind, alerts, button, icons, skeleton, spinner, theme}; use super::{Message, Phase, State}; + +fn service_status_alert<'a>( + state: crate::service_status::ServiceStatusState, + localizer: &'a Localizer, +) -> Option> { + use crate::service_status::ServiceStatusState; + + match state { + ServiceStatusState::Checking | ServiceStatusState::Operational => None, + ServiceStatusState::Advisory => Some(Alert::new( + AlertKind::Warning, + localizer.text(Text::HomeServiceStatusAdvisoryTitle), + localizer.text(Text::HomeServiceStatusAdvisoryMessage), + )), + ServiceStatusState::PartialOutage => Some(Alert::new( + AlertKind::Warning, + localizer.text(Text::HomeServiceStatusPartialTitle), + localizer.text(Text::HomeServiceStatusPartialMessage), + )), + ServiceStatusState::MajorOutage => Some(Alert::new( + AlertKind::Danger, + localizer.text(Text::HomeServiceStatusMajorTitle), + localizer.text(Text::HomeServiceStatusMajorMessage), + )), + ServiceStatusState::MonitoringUnavailable => Some(Alert::new( + AlertKind::Info, + localizer.text(Text::HomeServiceStatusUnavailableTitle), + localizer.text(Text::HomeServiceStatusUnavailableMessage), + )), + } +} + +fn service_status_view<'a>( + state: crate::service_status::ServiceStatusState, + localizer: &'a Localizer, + palette: theme::Palette, + icons_ready: bool, + spinner_phase: Duration, +) -> Option> { + service_status_alert(state, localizer).map(|alert| { + container( + column![ + alerts::view(alert, None, localizer, palette, icons_ready, spinner_phase), + button::action( + localizer.text(Text::HomeServiceStatusLink), + button::Spec::secondary_ghost(), + button::State::Enabled(Message::OpenServiceStatus), + palette, + icons_ready, + spinner_phase, + ), + ] + .spacing(theme::SPACE_8), + ) + .max_width(680) + .into() + }) +} + fn content<'a>( state: &'a State, localizer: &'a Localizer, @@ -137,8 +196,18 @@ fn content<'a>( timeline, Message::PullFeedback, ); + let status = service_status_view( + state.service_status.state(), + localizer, + palette, + icons_ready, + spinner_phase, + ); let freshness = freshness_lane(state, localizer, palette, icons_ready, spinner_phase); let mut content = column![].width(Length::Fill).height(Length::Fill); + if let Some(status) = status { + content = content.push(status); + } if let Some(freshness) = freshness { content = content.push(freshness); } @@ -149,6 +218,17 @@ fn content<'a>( return body; } + let body = if let Some(status) = service_status_view( + state.service_status.state(), + localizer, + palette, + icons_ready, + spinner_phase, + ) { + column![status, body].spacing(theme::SPACE_16).into() + } else { + body + }; let group = container(body) .padding(theme::SPACE_20) .width(Length::Fill) @@ -516,4 +596,31 @@ mod tests { .request(RefreshTrigger::Manual); assert_eq!(freshness_status(&state), Some(FreshnessStatus::Refreshing)); } + + #[test] + fn service_status_alert_selects_only_non_operational_states() { + let localizer = crate::ui::localization::Localizer::new_en_us().expect("catalog"); + assert!( + super::service_status_alert( + crate::service_status::ServiceStatusState::Checking, + &localizer, + ) + .is_none() + ); + assert!( + super::service_status_alert( + crate::service_status::ServiceStatusState::Operational, + &localizer, + ) + .is_none() + ); + for state in [ + crate::service_status::ServiceStatusState::Advisory, + crate::service_status::ServiceStatusState::PartialOutage, + crate::service_status::ServiceStatusState::MajorOutage, + crate::service_status::ServiceStatusState::MonitoringUnavailable, + ] { + assert!(super::service_status_alert(state, &localizer).is_some()); + } + } } diff --git a/src/ui/localization.rs b/src/ui/localization.rs index 234eac4..3ba022a 100644 --- a/src/ui/localization.rs +++ b/src/ui/localization.rs @@ -116,6 +116,15 @@ pub(super) enum Text { HomeLiveRefreshTargetsUnavailableMessage, HomeLiveRefreshConnectionUnavailableMessage, HomeLiveRefreshInterruptedMessage, + HomeServiceStatusAdvisoryTitle, + HomeServiceStatusAdvisoryMessage, + HomeServiceStatusPartialTitle, + HomeServiceStatusPartialMessage, + HomeServiceStatusMajorTitle, + HomeServiceStatusMajorMessage, + HomeServiceStatusUnavailableTitle, + HomeServiceStatusUnavailableMessage, + HomeServiceStatusLink, HomeSettings, HomeLoadingTitle, HomeLoadingMessage, @@ -328,6 +337,15 @@ impl Text { Self::HomeLiveRefreshTargetsUnavailableMessage, Self::HomeLiveRefreshConnectionUnavailableMessage, Self::HomeLiveRefreshInterruptedMessage, + Self::HomeServiceStatusAdvisoryTitle, + Self::HomeServiceStatusAdvisoryMessage, + Self::HomeServiceStatusPartialTitle, + Self::HomeServiceStatusPartialMessage, + Self::HomeServiceStatusMajorTitle, + Self::HomeServiceStatusMajorMessage, + Self::HomeServiceStatusUnavailableTitle, + Self::HomeServiceStatusUnavailableMessage, + Self::HomeServiceStatusLink, Self::HomeSettings, Self::HomeLoadingTitle, Self::HomeLoadingMessage, @@ -536,6 +554,15 @@ impl Text { "home-live-refresh-connection-unavailable-message" } Self::HomeLiveRefreshInterruptedMessage => "home-live-refresh-interrupted-message", + Self::HomeServiceStatusAdvisoryTitle => "home-service-status-advisory-title", + Self::HomeServiceStatusAdvisoryMessage => "home-service-status-advisory-message", + Self::HomeServiceStatusPartialTitle => "home-service-status-partial-title", + Self::HomeServiceStatusPartialMessage => "home-service-status-partial-message", + Self::HomeServiceStatusMajorTitle => "home-service-status-major-title", + Self::HomeServiceStatusMajorMessage => "home-service-status-major-message", + Self::HomeServiceStatusUnavailableTitle => "home-service-status-unavailable-title", + Self::HomeServiceStatusUnavailableMessage => "home-service-status-unavailable-message", + Self::HomeServiceStatusLink => "home-service-status-link", Self::HomeSettings => "home-settings", Self::HomeLoadingTitle => "home-loading-title", Self::HomeLoadingMessage => "home-loading-message", @@ -941,6 +968,43 @@ mod tests { "Settings may not persist" ); } + + #[test] + fn service_status_copy_is_exact_and_punctuated() { + let localizer = Localizer::new_en_us().unwrap(); + assert_eq!( + localizer.text(Text::HomeServiceStatusAdvisoryTitle), + "Bluesky is investigating an issue" + ); + assert_eq!( + localizer.text(Text::HomeServiceStatusAdvisoryMessage), + "Some Bluesky features may be affected." + ); + assert_eq!( + localizer.text(Text::HomeServiceStatusPartialTitle), + "Bluesky is experiencing a partial outage" + ); + assert_eq!( + localizer.text(Text::HomeServiceStatusPartialMessage), + "Some Bluesky features may be unavailable or delayed." + ); + assert_eq!( + localizer.text(Text::HomeServiceStatusMajorTitle), + "Bluesky is experiencing a major outage" + ); + assert_eq!( + localizer.text(Text::HomeServiceStatusMajorMessage), + "Bluesky may be temporarily unavailable." + ); + assert_eq!( + localizer.text(Text::HomeServiceStatusUnavailableTitle), + "Bluesky status is unavailable" + ); + assert_eq!( + localizer.text(Text::HomeServiceStatusUnavailableMessage), + "Hitomi can’t currently verify Bluesky’s service status." + ); + } #[test] fn typed_formatters_isolate_placeables() { let localizer = Localizer::new_en_us().unwrap(); diff --git a/tests/service_status_provider_qualification.rs b/tests/service_status_provider_qualification.rs new file mode 100644 index 0000000..f3f533a --- /dev/null +++ b/tests/service_status_provider_qualification.rs @@ -0,0 +1,13 @@ +#![cfg(feature = "test-support")] + +use hitomi::infrastructure::service_status::qualify_live; + +/// Network qualification intentionally returns only the provider-neutral health +/// enum; it neither prints nor retains provider response content. +#[tokio::test] +#[ignore = "requires the public Bluesky status page"] +async fn production_provider_contract_maps_a_bounded_known_health() { + qualify_live() + .await + .expect("public Bluesky status provider must yield a bounded known health"); +}