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/mod.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
3
mod events;
4
mod handlers;
5
mod init;
6
mod small_state;
7
mod subscription;
8
mod update;
9
pub(crate) mod updater;
10
mod view;
11
use subscription::{UdpSubscriptionId, audio_events_subscription};
12
13
use crate::daemon::backends::BackendInfo;
14
use crate::daemon::client::v1::pipeline::device::ModelDevice;
15
use crate::state::{AudioTheme, ContextPage, DaemonStatus, MenuAction, SpeakingStatus};
16
use crate::ui::messages::{DaemonMessage, Message, ModelsPageMessage, ShellMessage};
17
use cosmic::app::context_drawer;
18
use cosmic::iced::Subscription;
19
use cosmic::prelude::*;
20
use cosmic::widget::{menu, nav_bar};
21
use std::collections::HashMap;
22
use std::path::PathBuf;
23
24
/// Unified model operation state that encompasses downloading, loading, and switching
25
#[derive(Debug, Clone)]
26
pub enum ModelOperationState {
27
    /// Model is ready for use
28
    Ready,
29
    /// Provisioning the model's files: verifying the copies already on disk,
30
    /// and downloading the ones that are missing or stale. One state for both
31
    /// phases because the daemon reports them through one byte-tracked
32
    /// progress shape and the UI treats them alike; `progress.status`
33
    /// (`"verifying"` / `"downloading"`) says which is running, and is the only
34
    /// thing the card's wording depends on.
35
    Provisioning {
36
        target_model: String,
37
        progress: super_tts_shared::models::protocol::DownloadProgress,
38
    },
39
    /// Loading model into memory (after the files are all on disk)
40
    Loading {
41
        target_model: String,
42
        status_message: String,
43
        /// What the backend reports of its load — a first-time setup, the
44
        /// step it is on, how far through — once it reports anything.
45
        load: Option<super_tts_shared::models::protocol::LoadProgress>,
46
    },
47
    /// Model operation failed
48
    Error { message: String },
49
}
50
51
/// Whether a device change is in flight for the model the stage is running.
52
///
53
/// Only a change to the *loaded* model's device reloads anything; a change to
54
/// any other model is a note for its next load and passes through this state
55
/// untouched. The daemon narrates the reload over `/events`
56
/// (`switching_device`, then `loading_model_for_device`, then `ready`), and
57
/// this is what lets those arrive in any order without the app deciding a
58
/// switch finished before it started.
59
#[derive(Debug, Clone, PartialEq)]
60
pub enum DeviceState {
61
    Ready,
62
    Switching {
63
        target_device: String,
64
        status_message: String,
65
    },
66
}
67
68
/// The application model stores app-specific state used to describe its interface and
69
/// drive its logic.
70
// reason: AppModel mirrors discrete UI toggles; COSMIC apps accumulate independent bool flags.
71
#[allow(clippy::struct_excessive_bools)]
72
pub struct AppModel {
73
    /// Application state which is managed by the COSMIC runtime.
74
    core: cosmic::Core,
75
    /// Display a context drawer with the designated page if defined.
76
    context_page: ContextPage,
77
    /// Contains items assigned to the nav bar panel.
78
    nav: nav_bar::Model,
79
80
    // Super TTS specific state
81
    /// Socket path for daemon communication
82
    pub socket_path: PathBuf,
83
    /// Current daemon connection status
84
    pub daemon_status: DaemonStatus,
85
    /// Reconnect backoff (shared `RetryStrategy`: exponential + jitter),
86
    /// advanced on each failed reconnect and reset on a successful connection.
87
    pub reconnect_retry: super_tts_shared::daemon::retry::RetryStrategy,
88
    /// Whether the daemon is speaking right now.
89
    pub speaking_status: SpeakingStatus,
90
    /// The utterance the daemon is playing, when it named one. Not necessarily
91
    /// this app's: any client can ask the daemon to speak, and the badge
92
    /// reflects the device, not this page.
93
    pub speaking_utterance: Option<String>,
94
    /// Text in the Speech page's test field.
95
    pub speech_test_text: String,
96
    /// Current audio level (0.0 to 1.0)
97
    pub audio_level: f32,
98
    /// Whether audio is currently coming out of the speakers.
99
    pub is_speech_detected: bool,
100
    /// Available audio themes
101
    pub audio_themes: Vec<AudioTheme>,
102
    /// Currently selected audio theme
103
    pub selected_audio_theme: AudioTheme,
104
    /// Last non-silent theme (to restore when toggling audio feedback back on)
105
    pub last_non_silent_theme: AudioTheme,
106
    /// UDP restart counter for subscriptions
107
    pub udp_restart_counter: u64,
108
    /// Last UDP data timestamp
109
    pub last_udp_data: std::time::Instant,
110
111
    // Model management state
112
    /// Available models from daemon as `(name, source)` tuples.
113
    pub available_models: Vec<(String, String)>,
114
    /// Currently loaded model
115
    pub current_model: String,
116
    /// Source (serving backend repo id) of the currently loaded model
117
    pub current_source: String,
118
    /// Monotonic counter bumped every time a live daemon event (e.g.
119
    /// `model_switched`) updates the model identity. A point-in-time stage-view
120
    /// snapshot captures this at issue time and is discarded on arrival if the
121
    /// counter has since advanced — so a slow, stale reconnect query can never
122
    /// clobber a fresher live event.
123
    pub current_model_epoch: u64,
124
    /// Model operation state (downloading, loading, or ready)
125
    pub model_operation_state: ModelOperationState,
126
127
    // Device management state.
128
    //
129
    // The device preference belongs to a *model*, not to the daemon. A small
130
    // preset-voice model runs fine on the CPU while the cloning model beside it
131
    // needs the GPU, and the single global setting this replaced meant that
132
    // choosing for one silently overwrote the choice for the other. So the
133
    // answer is held per model and guarded by the pair it describes; the
134
    // stage-wide list is only what can be said before a model is picked.
135
    /// The accelerator the loaded model is actually running on (`cuda`, `rocm`,
136
    /// `cpu`, …); empty when nothing is loaded.
137
    ///
138
    /// Deliberately not a preference. A `gpu` choice that fell back to the CPU
139
    /// reads `cpu` here, which is the point: the card's "Active:" line would
140
    /// otherwise tell the user they are on a GPU they are not on. It is filled
141
    /// from the stage view's resolved accelerator and from the daemon's `ready`
142
    /// event — never from what the user staged.
143
    pub current_device: String,
144
    /// The devices the stage's selected backend can run models on here, from
145
    /// `GET /pipeline/{stage}/device/list`.
146
    ///
147
    /// The union over the models that backend serves, and so the only device
148
    /// answer available before a model is picked. It keeps the card's device
149
    /// control populated in the beat between staging a model and that model's
150
    /// own, narrower list arriving, rather than letting the control appear a
151
    /// moment after the model dropdown it sits beside. Empty until a backend is
152
    /// selected.
153
    pub stage_devices: Vec<String>,
154
    /// One model's device answer: the preference it will load with, what that
155
    /// resolved to, and the devices this host can offer it.
156
    ///
157
    /// A single slot rather than a map, mirroring `language.model_language`:
158
    /// the card only ever asks about the model it is showing, and a map would
159
    /// keep answers for models whose backend has since been swapped out — the
160
    /// stalest possible thing to render a device picker from.
161
    pub model_device: Option<ModelDevice>,
162
    /// Which `(source, model)` pair [`Self::model_device`] describes. Reading
163
    /// the block without checking this is how a card ends up showing one
164
    /// model's device list under another model's name.
165
    pub model_device_for: Option<(String, String)>,
166
    /// GPU inventory + memory from the daemon's `GET /gpu_info` (gpu-probe).
167
    pub gpu_info: Vec<super_tts_shared::models::protocol::GpuInfo>,
168
    /// Device switching state
169
    pub device_state: DeviceState,
170
    /// Timestamp of the last model-switch progress signal (switch start or any
171
    /// `download_progress` tick). Drives the stall watchdog in the
172
    /// `PingTimeout` handler so a switch that stops making progress surfaces an
173
    /// error instead of spinning forever. `None` when no switch is in flight.
174
    pub last_switch_progress_at: Option<std::time::Instant>,
175
    /// Last event timestamp for polling daemon events
176
    pub last_event_timestamp: Option<String>,
177
178
    // Notification method
179
    pub notification_method: super_tts_shared::models::notification_method::NotificationMethod,
180
181
    // Master volume (0-100)
182
    pub volume: u8,
183
    /// Last value successfully committed to (or loaded from) the daemon. The
184
    /// rollback target when a `VolumeCommit` save fails, since the live drag has
185
    /// already overwritten `volume` (audit Tier 3 #37).
186
    pub last_committed_volume: u8,
187
188
    // Custom models directory
189
    pub custom_models_dir: Option<String>,
190
    pub custom_models_dir_input: String,
191
192
    // Models page UI state (tabs, active-backend card selection/staging, menus).
193
    pub models_page: crate::state::models_page::ModelsPageState,
194
195
    // Speech language state (global Primary Language + per-model picker).
196
    pub language: crate::state::language::LanguageState,
197
198
    // The active model's voice: which one it speaks in, and which it could.
199
    pub voice: crate::state::VoiceState,
200
201
    // Installed-backend catalog and per-backend configuration state.
202
    /// Backends discovered by the daemon, with the models/secrets/options
203
    /// each declares. Drives the per-backend sections on the Models page.
204
    pub backends: Vec<BackendInfo>,
205
    /// The subset of [`Self::backends`] that can fill the synthesis stage, as
206
    /// `GET /pipeline/{stage}/backend/list` reports it.
207
    ///
208
    /// What the "Select a backend" sheet may offer. Selecting a backend that
209
    /// serves nothing this stage can run is refused by the daemon, so a sheet
210
    /// built from the full catalog offers picks that fail on click — and while
211
    /// synthesis is the only stage the two lists are identical, which is
212
    /// exactly what would let the wrong one stand in unnoticed.
213
    pub stage_backends: Vec<BackendInfo>,
214
    /// In-progress text for each secret input, keyed by `(source, name)`.
215
    pub backend_secret_inputs: HashMap<(String, String), String>,
216
    /// Whether each declared secret is currently configured, as reported by
217
    /// the daemon, keyed by `(source, name)`. Refreshed on catalog load.
218
    pub backend_secret_configured: HashMap<(String, String), bool>,
219
    /// In-progress text for each option input, keyed by `(source, name)`.
220
    pub backend_option_inputs: HashMap<(String, String), String>,
221
222
    // Registry state (catalog + install progress for the Download tab).
223
    pub registry: crate::state::registry::RegistryState,
224
225
    // Self-update state (Updates page + header badge + apply flow).
226
    pub update: crate::state::update::UpdateState,
227
228
    // The cloned-voice library, plus the recording or import in progress.
229
    pub voices: crate::state::voices::VoicesState,
230
231
    /// Scope-tagged banner for a failed settings/backend save. Rendered inline
232
    /// on the owning page instead of hijacking the UI (Tier 1 #13) or being
233
    /// dropped to the log (Tier 1 #15). `None` when there is no pending error.
234
    pub action_error: Option<crate::state::ActionError>,
235
}
236
237
impl AppModel {
238
    /// The pending [`action_error`](Self::action_error) message iff it is tagged
239
    /// for `scope` — used by each page's view to render its own inline banner.
240
    #[must_use]
241
0
    pub fn action_error_for(&self, scope: crate::state::ErrorScope) -> Option<&str> {
242
0
        self.action_error
243
0
            .as_ref()
244
0
            .filter(|e| e.scope == scope)
245
0
            .map(|e| e.message.as_str())
246
0
    }
247
248
    /// Park a failed action in the single scope-tagged banner slot. The one
249
    /// slot is deliberate — only one page is visible at a time, and
250
    /// [`action_error_for`](Self::action_error_for) gates rendering by scope.
251
0
    pub fn set_action_error(&mut self, scope: crate::state::ErrorScope, message: String) {
252
0
        self.action_error = Some(crate::state::ActionError { scope, message });
253
0
    }
254
255
    /// Clear the banner only if it currently belongs to `scope`, so retrying or
256
    /// succeeding at one page's action can't wipe another page's pending error.
257
0
    pub fn clear_action_error(&mut self, scope: crate::state::ErrorScope) {
258
0
        if self.action_error.as_ref().is_some_and(|e| e.scope == scope) {
259
0
            self.action_error = None;
260
0
        }
261
0
    }
262
263
    /// The daemon's device answer for one model, iff the held block is that
264
    /// model's. The pair check is the whole value of this accessor: staging a
265
    /// second model leaves the previous model's block in place until its own
266
    /// answer lands, and a device picker drawn from it would be showing the
267
    /// wrong model's options under the right model's name.
268
0
    fn model_device_answer(&self, source: &str, model: &str) -> Option<&ModelDevice> {
269
0
        let (held_source, held_model) = self.model_device_for.as_ref()?;
270
0
        if held_source != source || held_model != model {
271
0
            return None;
272
0
        }
273
0
        self.model_device.as_ref()
274
0
    }
275
276
    /// The devices this host can offer one model, as the daemon narrowed them.
277
    ///
278
    /// `None` is not "none offered" — it is the window between staging a model
279
    /// and its answer arriving. A caller that collapsed the two would announce
280
    /// "this model can't be loaded here" for a moment on every single pick,
281
    /// which is why the empty list and the missing answer stay distinguishable
282
    /// all the way up into the view.
283
    #[must_use]
284
0
    pub fn model_devices(&self, source: &str, model: &str) -> Option<&[String]> {
285
0
        self.model_device_answer(source, model)
286
0
            .map(|d| d.available_devices.as_slice())
287
0
    }
288
289
    /// The device preference the daemon has recorded for one model — what it
290
    /// would load with today, `cpu`/`gpu`, or `none` for a model that
291
    /// synthesizes remotely. `None` until the answer for this pair arrives.
292
    ///
293
    /// This is what a Load click compares its staged device against: writing a
294
    /// preference the daemon already holds costs a request and, for the model a
295
    /// stage is running, a reload of a model that is already where it belongs.
296
    #[must_use]
297
0
    pub fn model_device_preference(&self, source: &str, model: &str) -> Option<&str> {
298
0
        self.model_device_answer(source, model)
299
0
            .map(|d| d.device.as_str())
300
0
    }
301
}
302
303
/// Create a COSMIC application from the app model
304
impl cosmic::Application for AppModel {
305
    /// The async executor that will be used to run your application's commands.
306
    type Executor = cosmic::executor::Default;
307
308
    /// Data that your application receives to its init method.
309
    type Flags = ();
310
311
    /// Messages which the application and its widgets will emit.
312
    type Message = Message;
313
314
    /// Unique identifier in RDNN (reverse domain name notation) format.
315
    const APP_ID: &'static str = "ai.menjivar.super-tts-app";
316
317
0
    fn core(&self) -> &cosmic::Core {
318
0
        &self.core
319
0
    }
320
321
0
    fn core_mut(&mut self) -> &mut cosmic::Core {
322
0
        &mut self.core
323
0
    }
324
325
    /// Initializes the application with any given flags and startup commands.
326
0
    fn init(core: cosmic::Core, flags: Self::Flags) -> (Self, Task<cosmic::Action<Self::Message>>) {
327
0
        Self::init_model(core, flags)
328
0
    }
329
330
    /// Elements to pack at the start of the header bar.
331
0
    fn header_start(&self) -> Vec<Element<'_, Self::Message>> {
332
        // App name + logo, pinned to the header's leading edge so the window
333
        // always identifies itself. The logo keeps its own brand colors; the
334
        // name sits beside it in the header's text color.
335
0
        let brand = cosmic::widget::container(
336
0
            cosmic::widget::row::with_capacity(2)
337
0
                .align_y(cosmic::iced::Alignment::Center)
338
0
                .spacing(8.0)
339
0
                .push(crate::ui::icons::app_logo(22.0))
340
0
                .push(cosmic::widget::text("Super TTS").size(16.0)),
341
        )
342
0
        .padding([0, 12, 0, 8]);
343
344
0
        let menu_bar = menu::bar(vec![menu::Tree::with_children(
345
0
            menu::root("View").apply(Element::from),
346
0
            menu::items(
347
0
                &HashMap::new(),
348
0
                vec![menu::Item::Button("About", None, MenuAction::About)],
349
            ),
350
        )]);
351
352
0
        vec![brand.into(), menu_bar.into()]
353
0
    }
354
355
    /// Window header-bar readouts (right side): the GPU summary and model
356
    /// readiness pills.
357
0
    fn header_end(&self) -> Vec<Element<'_, Self::Message>> {
358
0
        self.header_end_impl()
359
0
    }
360
361
    /// Enables the COSMIC application to create a nav bar with this model.
362
0
    fn nav_model(&self) -> Option<&nav_bar::Model> {
363
0
        self.nav_model_impl()
364
0
    }
365
366
    /// Display a context drawer if the context page is requested.
367
0
    fn context_drawer(&self) -> Option<context_drawer::ContextDrawer<'_, Self::Message>> {
368
0
        self.context_drawer_impl()
369
0
    }
370
371
    /// Describes the interface based on the current state of the application model.
372
    ///
373
    /// Application events will be processed through the view. Any messages emitted by
374
    /// events received by widgets will be passed to the update method.
375
0
    fn view(&self) -> Element<'_, Self::Message> {
376
0
        self.view_impl()
377
0
    }
378
379
    /// Register subscriptions for this application.
380
    ///
381
    /// Subscriptions are long-running async tasks running in the background which
382
    /// emit messages to the application through a channel. They are started at the
383
    /// beginning of the application, and persist through its lifetime.
384
0
    fn subscription(&self) -> Subscription<Self::Message> {
385
        // Connection monitoring constants
386
        const PING_INTERVAL_SECS: u64 = 5;
387
        // GPU memory changes as models load/unload and as other processes use
388
        // the card, so poll it a bit faster than the ping to keep the header
389
        // readout (and the staged-load fit warning) live.
390
        const GPU_POLL_INTERVAL_SECS: u64 = 3;
391
392
        // The recording meter's refresh rate. Fast enough that a voice reads
393
        // as live movement rather than a stepping bar, slow enough to cost
394
        // nothing — and present only while the microphone is open, so an idle
395
        // app is not waking ten times a second to redraw nothing.
396
        const RECORDING_TICK_MS: u64 = 100;
397
398
0
        let mut subscriptions = vec![
399
            // HTTP /events SSE subscription. Covers the playback /
400
            // audio-meter topics and the model/device/download status
401
            // topics — the settings app's token holds every scope these
402
            // need, so one subscription carries the full set.
403
0
            Subscription::run_with(
404
0
                UdpSubscriptionId(self.udp_restart_counter),
405
0
                audio_events_subscription,
406
            ),
407
            // Periodic connection monitoring
408
0
            cosmic::iced::time::every(std::time::Duration::from_secs(PING_INTERVAL_SECS))
409
0
                .map(|_| Message::Daemon(DaemonMessage::PingTimeout)),
410
            // Periodic GPU inventory/memory refresh (gated on connection in the
411
            // handler, so it's a no-op while disconnected).
412
0
            cosmic::iced::time::every(std::time::Duration::from_secs(GPU_POLL_INTERVAL_SECS))
413
0
                .map(|_| Message::ModelsPage(ModelsPageMessage::RefreshGpuInfo)),
414
        ];
415
0
        if self.voices.is_recording() {
416
0
            subscriptions.push(
417
0
                cosmic::iced::time::every(std::time::Duration::from_millis(RECORDING_TICK_MS))
418
0
                    .map(|_| Message::Voices(crate::ui::messages::VoicesMessage::RecordingTick)),
419
            );
420
0
        }
421
        // The Add-a-backend drawer's spinner is a still SVG per angle, so it
422
        // needs a tick to step through them. Only while a check is actually in
423
        // flight: outside that the drawer draws no spinner, and a timer left
424
        // running would wake the app for nothing.
425
0
        if self.registry.add_preview.is_checking() {
426
0
            subscriptions.push(
427
0
                cosmic::iced::time::every(crate::ui::icons::SPINNER_FRAME)
428
0
                    .map(|_| Message::ModelsPage(ModelsPageMessage::AddPreviewSpin)),
429
            );
430
0
        }
431
0
        Subscription::batch(subscriptions)
432
0
    }
433
434
    /// Handles messages emitted by the application and its widgets.
435
    ///
436
    /// Tasks may be returned for asynchronous execution of code in the background
437
    /// on the application's async runtime.
438
0
    fn update(&mut self, message: Self::Message) -> Task<cosmic::Action<Self::Message>> {
439
0
        self.dispatch(message)
440
0
    }
441
442
    /// Called when a nav item is selected.
443
0
    fn on_nav_select(&mut self, id: nav_bar::Id) -> Task<cosmic::Action<Self::Message>> {
444
        // Activate the page in the model.
445
0
        self.nav.activate(id);
446
        // Leaving the current section dismisses any open context sheet
447
        // (e.g. the Models page's "Add backend" / configuration drawer) and
448
        // any open per-card overflow menu.
449
0
        self.core.window.show_context = false;
450
0
        self.models_page.installed_menu_open = None;
451
0
        self.models_page.browse_menu_open = false;
452
453
        // Opening a page that draws backend cards refetches both catalogs. The
454
        // daemon reads `installed_version` off disk on every request, so it is
455
        // never the stale one — the app was, holding a catalog fetched once at
456
        // startup. Anything that changed a backend outside this app (an install
457
        // from the CLI, an edited `backend.toml`) went unseen until a restart.
458
0
        match self.nav.data::<crate::state::Page>(id) {
459
0
            Some(crate::state::Page::Models | crate::state::Page::Library) => Task::batch([
460
0
                handlers::tasks::reload_backend_catalogs(),
461
0
                self.update_title(),
462
0
            ]),
463
            // Re-fetch rather than trust whatever was loaded at connect-time —
464
            // a periodic background check may have completed since, or another
465
            // client may have changed the beta-opt-in setting.
466
0
            Some(crate::state::Page::Updates) => Task::batch([
467
0
                handlers::tasks::refresh_update_status(),
468
0
                self.update_title(),
469
0
            ]),
470
            // The library and the loaded model's cloning capability are both
471
            // read here rather than held from connect-time: a model switch
472
            // since then changes what the page says it can do, and another
473
            // client may have added or removed a voice.
474
0
            Some(crate::state::Page::Voices) => Task::batch([
475
0
                self.dispatch(Message::Voices(crate::ui::messages::VoicesMessage::Refresh)),
476
0
                self.update_title(),
477
0
            ]),
478
0
            _ => self.update_title(),
479
        }
480
0
    }
481
}
482
483
impl menu::action::MenuAction for MenuAction {
484
    type Message = Message;
485
486
0
    fn message(&self) -> Self::Message {
487
0
        match self {
488
            MenuAction::About => {
489
0
                Message::Shell(ShellMessage::ToggleContextPage(ContextPage::About))
490
            }
491
        }
492
0
    }
493
}