Coverage Report

Created: 2026-11-04 02:14

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
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
}