Coverage Report

Created: 2026-09-05 23:00

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-stt-app/src/ui/views/updates.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
//! Updates page: current/latest version, automatic-check and beta-opt-in
3
//! settings, and the apply flow (download → spawn → JSON progress).
4
5
use cosmic::Element;
6
use cosmic::iced::widget::{column, row};
7
use cosmic::iced::{Alignment, Length};
8
use cosmic::widget::{self, settings, text};
9
10
use super_stt_shared::models::self_update::SelfUpdateStatus;
11
12
use super::common::{error_banner, page_layout};
13
use super::models::{accent_button_class, pill_label_tinted, rounded_tooltip};
14
use crate::core::app::AppModel;
15
use crate::state::update::{RunPhase, UpdateState};
16
use crate::ui::messages::{Message, UpdateMessage};
17
18
const INSTALL_SH_URL: &str =
19
    "https://raw.githubusercontent.com/jorge-menjivar/super-stt/main/install.sh";
20
21
/// A tag is a prerelease iff it carries a semver `-<identifier>` suffix
22
/// (e.g. `v0.2.3-beta.1`) — the same rule `updater::run_update_stream` uses
23
/// to decide whether to pass `--beta` to the installer.
24
16
fn tag_is_prerelease(tag: &str) -> bool {
25
16
    tag.contains('-')
26
16
}
27
28
/// The candidate tag to show/build messages around, falling back to a
29
/// generic label when `status` is absent (a `Failed`/idle run can render
30
/// while a fresh status hasn't loaded, e.g. right after `DismissRun`).
31
0
fn tag_of(status: Option<&SelfUpdateStatus>) -> &str {
32
0
    status
33
0
        .and_then(|s| s.latest_version.as_deref())
34
0
        .unwrap_or("the latest version")
35
0
}
36
37
/// The curl-bootstrap command, with the `--beta` flag a prerelease tag needs.
38
/// Shared by the two captions below so the command itself is written once.
39
12
fn curl_bootstrap(tag: &str) -> String {
40
12
    let beta_flag = if tag_is_prerelease(tag) {
41
4
        " -s -- --beta"
42
    } else {
43
8
        ""
44
    };
45
12
    format!("curl -sSL {INSTALL_SH_URL} | bash{beta_flag}")
46
12
}
47
48
/// The curl-bootstrap fallback shown when the daemon reports an update but
49
/// published no installer asset for this host (unsupported arch, or the
50
/// release simply lacks one). There is no button to press in that case, and
51
/// this stands in for it.
52
6
fn curl_fallback_caption(tag: &str) -> String {
53
6
    format!(
54
        "Update available, but no installer asset was published for this system. Run: {}",
55
6
        curl_bootstrap(tag)
56
    )
57
6
}
58
59
/// The way forward offered after a run that *failed*.
60
///
61
/// Distinct from [`curl_fallback_caption`], which explains an update that
62
/// never had a button: reaching a failure means an asset was published,
63
/// downloaded and verified, so saying none was published is simply false —
64
/// and it was, on the panel a dismissed authorization prompt produced.
65
6
fn manual_install_caption(tag: &str) -> String {
66
6
    format!(
67
        "To install it outside the app, run: {}",
68
6
        curl_bootstrap(tag)
69
    )
70
6
}
71
72
0
fn phase_label(phase: RunPhase) -> &'static str {
73
0
    match phase {
74
0
        RunPhase::FetchingInstaller => "Downloading installer…",
75
0
        RunPhase::Resolve => "Resolving…",
76
0
        RunPhase::Download => "Downloading update…",
77
0
        RunPhase::Verify => "Verifying…",
78
0
        RunPhase::Stage => "Staging…",
79
        RunPhase::WaitingAuth => {
80
0
            "Waiting for authorization — enter your password in the system dialog"
81
        }
82
0
        RunPhase::Install => "Installing…",
83
0
        RunPhase::PostInstall => "Finishing…",
84
        // Rendered through their own branches in `update_body`, never through
85
        // this label.
86
0
        RunPhase::Done | RunPhase::Failed => "",
87
    }
88
0
}
89
90
/// Version section: current/latest version, last-checked time (+ error
91
/// caption), and the "Check now" button.
92
0
fn version_section(state: &UpdateState) -> Element<'_, Message> {
93
0
    let status = state.status.as_ref();
94
0
    let latest = status
95
0
        .and_then(|s| s.latest_version.as_deref())
96
0
        .unwrap_or("—");
97
0
    let checked_at = status
98
0
        .and_then(|s| s.checked_at.as_deref())
99
0
        .unwrap_or("never");
100
101
0
    let mut checked_item = settings::item::builder("Last checked");
102
0
    if let Some(err) = status.and_then(|s| s.last_check_error.as_deref()) {
103
0
        checked_item = checked_item.description(err.to_string());
104
0
    }
105
106
0
    let mut check_button = widget::button::standard("Check now");
107
0
    if state.can_check_now() {
108
0
        check_button = check_button.on_press(Message::Update(UpdateMessage::CheckNow));
109
0
    }
110
111
0
    settings::section()
112
0
        .title("Version")
113
0
        .add(
114
0
            settings::item::builder("Current version")
115
0
                .control(text::body(env!("CARGO_PKG_VERSION"))),
116
        )
117
0
        .add(settings::item::builder("Latest version").control(text::body(latest.to_string())))
118
0
        .add(checked_item.control(text::body(checked_at.to_string())))
119
0
        .add(settings::item::builder("Check for updates").control(check_button))
120
0
        .into()
121
0
}
122
123
/// Settings section: the two togglers, both disabled while a run is active.
124
0
fn settings_section(state: &UpdateState) -> Element<'_, Message> {
125
0
    let run_active = state.run.is_some();
126
127
0
    settings::section()
128
0
        .title("Settings")
129
0
        .add(
130
0
            settings::item::builder("Automatic update checks")
131
0
                .description("Periodically check for new releases and notify when one is found")
132
0
                .control(
133
0
                    widget::toggler(state.auto_check_enabled.unwrap_or(true)).on_toggle_maybe(
134
0
                        (!run_active)
135
0
                            .then_some(|b| Message::Update(UpdateMessage::AutoCheckToggled(b))),
136
                    ),
137
                ),
138
        )
139
0
        .add(
140
0
            settings::item::builder("Receive beta updates")
141
0
                .description("Consider prerelease versions when checking for updates")
142
0
                .control(
143
                    // Inert while the write and its re-check are in flight:
144
                    // the switch has already moved, and a second press would
145
                    // race the first.
146
0
                    widget::toggler(state.beta_optin_shown()).on_toggle_maybe(
147
0
                        (!run_active && !state.beta_pending)
148
0
                            .then_some(|b| Message::Update(UpdateMessage::BetaOptinToggled(b))),
149
                    ),
150
                ),
151
        )
152
0
        .into()
153
0
}
154
155
/// The dynamic content of the Update section's single row: the idle CTA
156
/// (gated on `update_available` and an installer asset), the in-progress
157
/// phase and byte progress with Cancel, the Done banner, or the Failed
158
/// error — keyed off `state.run` first, `status` only for the idle
159
/// CTA/fallback text.
160
///
161
/// `run` always wins over the idle CTA, regardless of what `status` says
162
/// right now: a `Done`/`Failed` run's own panel (Restart/Dismiss, or the
163
/// error + Dismiss) must stay visible even after a post-update refetch
164
/// reports `update_available: false` — see `update_section`'s gate and
165
/// `UpdateState::clear_run_on_status_refresh`.
166
// reason: byte-count → progress-bar fraction is intentionally lossy/cosmetic.
167
#[allow(clippy::cast_precision_loss)]
168
0
fn update_body<'a>(
169
0
    state: &'a UpdateState,
170
0
    status: Option<&'a SelfUpdateStatus>,
171
0
) -> Element<'a, Message> {
172
0
    let spacing = cosmic::theme::spacing().space_xs;
173
0
    let tag = tag_of(status);
174
175
0
    let Some(run) = state.run.as_ref() else {
176
        // Idle: the CTA, or (no published asset for this host) the curl
177
        // fallback caption in place of a live button. `update_section` only
178
        // reaches this branch (no run) when `update_available` was true, so
179
        // `status` is always populated here.
180
0
        if status.is_none() {
181
0
            return text::body("").into();
182
0
        }
183
0
        return if state.can_start_update() {
184
0
            widget::button::suggested(format!("Update to {tag}"))
185
0
                .on_press(Message::Update(UpdateMessage::StartUpdate))
186
0
                .into()
187
        } else {
188
0
            column![
189
0
                widget::button::suggested(format!("Update to {tag}")),
190
0
                text::caption(curl_fallback_caption(tag)),
191
            ]
192
0
            .spacing(spacing)
193
0
            .into()
194
        };
195
    };
196
197
0
    match run.phase {
198
        RunPhase::Done => {
199
0
            let mut col = column![text::caption("Update installed.")].spacing(spacing);
200
0
            if run.completed_components.iter().any(|c| c == "app") {
201
0
                col = col.push(
202
0
                    row![
203
0
                        text::body("Restart Super STT to finish the update"),
204
0
                        widget::button::suggested("Restart")
205
0
                            .on_press(Message::Update(UpdateMessage::RestartApp)),
206
                    ]
207
0
                    .align_y(Alignment::Center)
208
0
                    .spacing(spacing),
209
                );
210
                // A failed relaunch attempt (RestartApp's spawn erred) is
211
                // surfaced here rather than silently exiting the app with
212
                // nothing left running — see handlers/update.rs::RestartApp.
213
0
                if let Some(err) = run.error.as_deref() {
214
0
                    col = col.push(error_banner(err));
215
0
                }
216
0
            }
217
0
            col.push(dismiss_button()).into()
218
        }
219
        RunPhase::Failed => {
220
0
            let mut col = column![error_banner(
221
0
                run.error.as_deref().unwrap_or("Update failed")
222
            )]
223
0
            .spacing(spacing);
224
            // Declining the prompt is not a dead end — pressing Update again
225
            // and authorizing is the answer, so a shell command here would
226
            // read as a non-sequitur to someone who just pressed Cancel.
227
0
            if !run.was_authorization_declined() {
228
0
                col = col.push(text::caption(manual_install_caption(tag)));
229
0
            }
230
0
            col.push(dismiss_button()).into()
231
        }
232
0
        phase => {
233
0
            let mut col = column![text::body(phase_label(phase))].spacing(spacing);
234
0
            if matches!(phase, RunPhase::FetchingInstaller | RunPhase::Download) {
235
0
                let fraction = (run.bytes_done as f32 / run.bytes_total.max(1) as f32).max(0.05);
236
0
                col = col.push(widget::determinate_linear(fraction).width(Length::Fill));
237
0
            }
238
0
            if run.cancellable() {
239
0
                col = col.push(
240
0
                    widget::button::destructive("Cancel")
241
0
                        .on_press(Message::Update(UpdateMessage::CancelUpdate)),
242
0
                );
243
0
            }
244
0
            col.into()
245
        }
246
    }
247
0
}
248
249
/// The "Dismiss" button shown on a terminal (`Done`/`Failed`) run's panel —
250
/// clears it without restarting, so the page returns to the idle CTA and a
251
/// future `StartUpdate` isn't permanently blocked.
252
0
fn dismiss_button<'a>() -> Element<'a, Message> {
253
0
    widget::button::standard("Dismiss")
254
0
        .on_press(Message::Update(UpdateMessage::DismissRun))
255
0
        .into()
256
0
}
257
258
/// Update section: rendered whenever there's an in-flight/terminal run to
259
/// show (independent of the current `update_available` flag — a `Done` run's
260
/// Restart CTA must survive the post-update refetch that flips it to
261
/// `false`), or the daemon currently reports an update available.
262
0
fn update_section<'a>(
263
0
    state: &'a UpdateState,
264
0
    status: Option<&'a SelfUpdateStatus>,
265
0
) -> Option<Element<'a, Message>> {
266
0
    if !state.update_section_visible() {
267
0
        return None;
268
0
    }
269
0
    Some(
270
0
        settings::section()
271
0
            .title("Update")
272
0
            .add(update_body(state, status))
273
0
            .into(),
274
0
    )
275
0
}
276
277
/// Updates page: version info, automatic-check/beta togglers, and (when the
278
/// daemon reports one available) the update CTA / apply-flow progress.
279
0
pub fn page(state: &UpdateState) -> Element<'_, Message> {
280
0
    let mut blocks: Vec<Element<'_, Message>> = Vec::new();
281
282
0
    if let Some(message) = state.action_error.as_deref() {
283
0
        blocks.push(error_banner(message));
284
0
    }
285
286
0
    blocks.push(version_section(state));
287
288
0
    if state.unsupported {
289
0
        blocks.push(text::caption("The connected daemon predates update support.").into());
290
0
    } else {
291
0
        blocks.push(settings_section(state));
292
0
        if let Some(update) = update_section(state, state.status.as_ref()) {
293
0
            blocks.push(update);
294
0
        }
295
    }
296
297
0
    page_layout("Updates", settings::view_column(blocks))
298
0
}
299
300
/// Header-bar badge shown while an update is available and no apply run is
301
/// in flight (once a run starts, the phase readout on the Updates page is
302
/// the source of truth — the badge would otherwise duplicate/contradict it).
303
/// Mirrors the GPU/status pills' construction (`ui/views/models/mod.rs`).
304
0
pub(crate) fn header_badge(app: &AppModel) -> Option<Element<'_, Message>> {
305
0
    if !app.update.header_badge_visible() {
306
0
        return None;
307
0
    }
308
309
0
    let fg: cosmic::iced::Color = cosmic::theme::active().cosmic().accent.base.into();
310
0
    let inner = row![
311
0
        crate::ui::icons::phosphor_tinted(crate::ui::icons::ARROWS_CLOCKWISE, 14.0, fg),
312
0
        pill_label_tinted("Update available", fg),
313
    ]
314
0
    .spacing(6.0)
315
0
    .align_y(Alignment::Center);
316
317
    // A button, wearing the same accent styling as the backend Update chip:
318
    // it is the same kind of control — an available version, and the way to
319
    // get to it. As a bare pill it looked pressable and wasn't.
320
    //
321
    // The padding is [`header_pill`]'s fixed pixels, not theme spacing, for
322
    // the reason given there: the header bar's height does not grow with the
323
    // system spacing setting.
324
0
    let badge = widget::button::custom(inner)
325
0
        .padding([8, 12])
326
0
        .class(accent_button_class(
327
0
            cosmic::theme::active().cosmic().corner_radii.radius_xl,
328
        ))
329
0
        .on_press(Message::Update(UpdateMessage::OpenUpdatesPage));
330
331
0
    Some(rounded_tooltip(
332
0
        badge,
333
0
        text::body("A new Super STT version is ready to install — open the Updates page"),
334
0
        widget::tooltip::Position::Bottom,
335
0
    ))
336
0
}
337
338
#[cfg(test)]
339
mod tests {
340
    use super::{curl_fallback_caption, manual_install_caption, tag_is_prerelease};
341
342
    #[test]
343
2
    fn a_prerelease_tag_carries_the_beta_flag_into_both_captions() {
344
2
        assert!(tag_is_prerelease("v0.2.3-beta.1"));
345
2
        assert!(!tag_is_prerelease("v0.2.3"));
346
347
2
        assert!(manual_install_caption("v0.2.3-beta.1").ends_with("| bash -s -- --beta"));
348
2
        assert!(manual_install_caption("v0.2.3").ends_with("| bash"));
349
2
        assert!(curl_fallback_caption("v0.2.3-beta.1").ends_with("| bash -s -- --beta"));
350
2
        assert!(curl_fallback_caption("v0.2.3").ends_with("| bash"));
351
2
    }
352
353
    /// The regression: a run that FAILED had reported "no installer asset was
354
    /// published for this system", on a panel produced by dismissing the
355
    /// authorization prompt. Reaching a failure means an asset was published,
356
    /// downloaded and verified — the claim is false wherever a run got far
357
    /// enough to fail, so the two captions must not be interchangeable.
358
    #[test]
359
2
    fn only_the_idle_caption_claims_no_asset_was_published() {
360
2
        let idle = curl_fallback_caption("v0.2.3");
361
2
        let failed = manual_install_caption("v0.2.3");
362
363
2
        assert!(idle.contains("no installer asset was published"));
364
2
        assert!(
365
2
            !failed.contains("no installer asset was published"),
366
            "a failed run proves an asset existed: {failed}"
367
        );
368
2
        assert!(failed.contains("To install it outside the app"));
369
2
    }
370
}