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 | | } |