super-tts-app/src/core/app/subscription.rs
Line | Count | Source |
1 | | // SPDX-License-Identifier: GPL-3.0-only |
2 | | |
3 | | use crate::daemon::client::internal::session::SETTINGS_SCOPES; |
4 | | use crate::ui::messages::{DaemonMessage, Message}; |
5 | | use futures_util::SinkExt; |
6 | | use log::{info, warn}; |
7 | | |
8 | | use super::events::settings_widget_event_to_message; |
9 | | |
10 | | /// Wrapper for `Subscription::run_with` so the subscription restarts when the counter changes. |
11 | | #[derive(Hash)] |
12 | | pub(super) struct UdpSubscriptionId(pub(super) u64); |
13 | | |
14 | | /// Subscribe to the daemon's `/events` SSE stream for the settings UI's |
15 | | /// audio meter and speaking-state indicator. Reuses the token already |
16 | | /// cached for normal config calls — [`SETTINGS_SCOPES`] grants the |
17 | | /// playback / visualization / daemon-status topics below. |
18 | | pub(super) const SETTINGS_APP_ID: super_tts_shared::daemon::session::AppId = |
19 | | super_tts_shared::daemon::session::app_id("super-tts-app"); |
20 | | const SETTINGS_APP_NAME: &str = "Super TTS Settings App"; |
21 | | /// Topics the settings app subscribes to over `GET /events`. |
22 | | /// |
23 | | /// `speaking_state` drives the speaking badge (`playback_events`), |
24 | | /// `frequency_bands` drives the audio meter (`audio_visualization`), and |
25 | | /// `daemon_status_changed` / `download_progress` / `registry_install` |
26 | | /// drive the model-switch progress bar and Download-tab install cards |
27 | | /// (`daemon_status`). |
28 | | const SETTINGS_TOPICS: &[&str] = &[ |
29 | | "speaking_state", |
30 | | "frequency_bands", |
31 | | "daemon_status_changed", |
32 | | "download_progress", |
33 | | "registry_install", |
34 | | ]; |
35 | | |
36 | | /// Self-healing `/events` subscription for the settings UI's audio |
37 | | /// meter + speaking-status badge. Routes through the shared |
38 | | /// [`run_widget_subscription`] helper so silent drops, idle wedges, |
39 | | /// and daemon-side revocations all auto-recover with backoff. |
40 | 0 | pub(super) fn audio_events_subscription( |
41 | 0 | _id: &UdpSubscriptionId, |
42 | 0 | ) -> std::pin::Pin<Box<dyn cosmic::iced::futures::Stream<Item = Message> + Send>> { |
43 | | use futures_util::StreamExt; |
44 | | use super_tts_shared::daemon::widget_subscription::{ |
45 | | WidgetSubscriptionConfig, WidgetSubscriptionUpdate, run_widget_subscription, |
46 | | }; |
47 | | use super_tts_shared::validation::get_http_socket_path; |
48 | | |
49 | 0 | Box::pin(cosmic::iced::stream::channel(100, async |mut channel| { |
50 | 0 | let config = WidgetSubscriptionConfig::new( |
51 | | SETTINGS_APP_ID, |
52 | | SETTINGS_APP_NAME, |
53 | | SETTINGS_SCOPES, |
54 | | SETTINGS_TOPICS, |
55 | | ); |
56 | 0 | let mut updates = Box::pin(run_widget_subscription(get_http_socket_path(), config)); |
57 | 0 | info!("Settings subscription starting"); |
58 | 0 | while let Some(update) = updates.next().await { |
59 | 0 | let msg = match update { |
60 | | WidgetSubscriptionUpdate::Connected => { |
61 | | // A successful (re)subscribe of the live event stream. |
62 | | // Clears any sticky `Blocked` / `Error` state on the |
63 | | // daemon-status badge (without this the settings UI sits in |
64 | | // whatever state it was last in, e.g. Blocked from a denial |
65 | | // the user has since cleared) AND triggers a current-model |
66 | | // re-fetch now that live events are flowing — so a model |
67 | | // that finished loading before this subscription completed |
68 | | // is still picked up. |
69 | 0 | Message::Daemon(DaemonMessage::EventStreamConnected) |
70 | | } |
71 | 0 | WidgetSubscriptionUpdate::Event(evt) => { |
72 | 0 | match settings_widget_event_to_message(&evt) { |
73 | 0 | Some(m) => m, |
74 | 0 | None => continue, |
75 | | } |
76 | | } |
77 | 0 | WidgetSubscriptionUpdate::Disconnected { reason } => { |
78 | 0 | warn!("Settings /events disconnected ({reason}); reconnecting"); |
79 | 0 | continue; |
80 | | } |
81 | 0 | WidgetSubscriptionUpdate::NeedsReauth { reason } => { |
82 | 0 | warn!( |
83 | | "Settings session needs re-auth ({reason}); will re-consent on next attempt" |
84 | | ); |
85 | 0 | continue; |
86 | | } |
87 | 0 | WidgetSubscriptionUpdate::Blocked { reason } => { |
88 | 0 | warn!("Settings session blocked by user denial ({reason}); subscription ended"); |
89 | | // Forward to the update loop so the UI flips to |
90 | | // the Blocked state (Retry button) instead of |
91 | | // sitting silently with a dead audio meter. |
92 | 0 | Message::Daemon(DaemonMessage::WidgetBlocked(reason)) |
93 | | } |
94 | | }; |
95 | 0 | if channel.send(msg).await.is_err() { |
96 | 0 | break; |
97 | 0 | } |
98 | | } |
99 | 0 | info!("Settings subscription ended"); |
100 | 0 | })) |
101 | 0 | } |
102 | | |
103 | | #[cfg(test)] |
104 | | mod tests { |
105 | | use super::{SETTINGS_SCOPES, SETTINGS_TOPICS}; |
106 | | use super_tts_shared::daemon::widget_subscription::uncovered_topic; |
107 | | |
108 | | #[test] |
109 | 1 | fn settings_scopes_cover_subscribed_topics() { |
110 | | // Guard the hand-maintained SETTINGS_SCOPES / SETTINGS_TOPICS lists: |
111 | | // every subscribed topic must be granted by a requested scope, or the |
112 | | // daemon refuses the whole stream with `403 scope_denied`. The mapping |
113 | | // lives in super-tts-shared and is pinned to the daemon's |
114 | | // `Topic::required_scope`. |
115 | 1 | assert_eq!(uncovered_topic(SETTINGS_SCOPES, SETTINGS_TOPICS), None); |
116 | 1 | } |
117 | | } |