cadmus_core/view/
ota.rs

1use super::device_auth::DeviceAuthView;
2use super::dialog::Dialog;
3use super::input_field::InputField;
4use super::label::Label;
5use super::notification::Notification;
6use super::progress_bar::ProgressBar;
7use super::toggleable_keyboard::ToggleableKeyboard;
8use super::{
9    Align, Bus, EntryId, Event, Hub, Id, NotificationEvent, RenderData, RenderQueue, UpdateMode,
10    View, ViewId, ID_FEEDER,
11};
12use crate::color::WHITE;
13use crate::context::Context;
14use crate::device::CURRENT_DEVICE;
15use crate::font::{font_from_style, Fonts, NORMAL_STYLE};
16use crate::framebuffer::Framebuffer;
17use crate::geom::Rectangle;
18use crate::gesture::GestureEvent;
19use crate::github::device_flow;
20use crate::github::GithubClient;
21use crate::ota::{OtaClient, OtaError, OtaProgress};
22use crate::unit::scale_by_dpi;
23use crate::version::{get_current_version, VersionComparison};
24use crate::view::filler::Filler;
25use crate::view::github::GithubEvent;
26use crate::view::BIG_BAR_HEIGHT;
27use secrecy::SecretString;
28use std::thread;
29use tracing::{error, info};
30
31#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq)]
32pub enum OtaViewId {
33    Main,
34    SourceSelection,
35    PrInput,
36    DeviceAuth,
37}
38
39#[derive(Debug, Clone, Eq, PartialEq)]
40pub enum OtaEntryId {
41    DefaultBranch,
42    StableRelease,
43}
44
45/// Attempts to show the OTA update view with validation checks.
46///
47/// This function validates prerequisites before showing the OTA view:
48/// - Checks if WiFi is enabled
49///
50/// If validation fails, a notification is added to the view hierarchy instead.
51///
52/// # Arguments
53///
54/// * `view` - The parent view to add either OTA view or notification to
55/// * `hub` - Event hub for sending events
56/// * `rq` - Render queue for UI updates
57/// * `context` - Application context containing settings and WiFi state
58///
59/// # Returns
60///
61/// `true` if the OTA view was successfully shown, `false` if validation failed
62/// and a notification was shown instead.
63#[cfg_attr(
64    feature = "otel",
65    tracing::instrument(
66        skip_all, ret(level=tracing::Level::TRACE),
67        ret(level = tracing::Level::TRACE)
68    )
69)]
70pub fn show_ota_view(
71    view: &mut dyn View,
72    hub: &Hub,
73    rq: &mut RenderQueue,
74    context: &mut Context,
75) -> bool {
76    #[cfg(feature = "otel")]
77    tracing::trace!("showing ota view");
78
79    if !context.online {
80        let notif = Notification::new(
81            None,
82            "WiFi must be connected to check for updates.".to_string(),
83            false,
84            hub,
85            rq,
86            context,
87        );
88        view.children_mut().push(Box::new(notif) as Box<dyn View>);
89        return false;
90    }
91
92    let ota_view = OtaView::new(context);
93    view.children_mut()
94        .push(Box::new(ota_view) as Box<dyn View>);
95    true
96}
97
98/// Which download to resume after device flow authentication completes.
99#[derive(Debug, Clone)]
100enum PendingDownload {
101    DefaultBranch,
102    PrInputPending,
103    Pr(u32),
104}
105
106/// UI view for downloading and installing OTA updates from GitHub.
107///
108/// Manages two screens:
109/// 1. Source selection dialog - asks where to download from
110///    (Stable Release, Main Branch, or PR Build)
111/// 2. PR input screen - prompts for PR number input (only for PR Build)
112///
113/// Once a download starts, the view transitions to a full-screen progress
114/// screen showing a status label and a [`ProgressBar`]. On successful
115/// deployment the label updates to "Rebooting…" and the app reboots
116/// automatically via [`Event::Select`] with [`EntryId::Reboot`].
117///
118/// When a GitHub token is required but not present, the view pushes a
119/// [`DeviceAuthView`] child to guide the user through device flow
120/// authentication. Once authorized, the pending download resumes automatically.
121///
122/// # Security
123///
124/// The GitHub token is securely stored using `SecretString` to prevent
125/// accidental exposure in logs or debug output.
126pub struct OtaView {
127    id: Id,
128    rect: Rectangle,
129    children: Vec<Box<dyn View>>,
130    view_id: ViewId,
131    github_token: Option<SecretString>,
132    keyboard_index: Option<usize>,
133    pending_download: Option<PendingDownload>,
134    /// Index into `children` of the status `Label` shown during download.
135    status_label_index: Option<usize>,
136    /// Index into `children` of the `ProgressBar` shown during download.
137    progress_bar_index: Option<usize>,
138}
139
140impl OtaView {
141    /// Creates a new OTA view.
142    ///
143    /// Attempts to load a previously saved GitHub token from disk. If none is
144    /// found the view will prompt for device flow authentication when a
145    /// token-gated download is requested.
146    ///
147    /// Initially displays the source selection dialog asking where to
148    /// download updates from.
149    ///
150    /// # Arguments
151    ///
152    /// * `context` - Application context containing fonts and device information
153    #[cfg_attr(feature = "otel", tracing::instrument(skip_all))]
154    pub fn new(context: &mut Context) -> OtaView {
155        let id = ID_FEEDER.next();
156        let view_id = ViewId::Ota(OtaViewId::Main);
157        let (width, height) = CURRENT_DEVICE.dims;
158
159        let github_token = match device_flow::load_token() {
160            Ok(token) => token,
161            Err(e) => {
162                tracing::warn!(error = %e, "Failed to load saved GitHub token");
163                None
164            }
165        };
166
167        let mut children: Vec<Box<dyn View>> = Vec::new();
168
169        children.push(Box::new(Filler::new(
170            rect![0, 0, width as i32, height as i32],
171            WHITE,
172        )));
173
174        let source_dialog = Self::build_source_selection_dialog(context);
175        children.push(Box::new(source_dialog));
176
177        OtaView {
178            id,
179            rect: rect![0, 0, width as i32, height as i32],
180            children,
181            view_id,
182            github_token,
183            keyboard_index: None,
184            pending_download: None,
185            status_label_index: None,
186            progress_bar_index: None,
187        }
188    }
189
190    /// Builds the source selection dialog.
191    #[inline]
192    fn build_source_selection_dialog(context: &mut Context) -> Dialog {
193        let builder = Dialog::builder(
194            ViewId::Ota(OtaViewId::Main),
195            "Where to check for updates?".to_string(),
196        );
197
198        #[cfg(not(feature = "test"))]
199        let mut builder = builder;
200
201        #[cfg(not(feature = "test"))]
202        {
203            builder = builder.add_button(
204                "Stable Release",
205                Event::Select(EntryId::Ota(OtaEntryId::StableRelease)),
206            );
207        }
208
209        builder
210            .add_button(
211                "Main Branch",
212                Event::Select(EntryId::Ota(OtaEntryId::DefaultBranch)),
213            )
214            .add_button("PR Build", Event::Show(ViewId::Ota(OtaViewId::PrInput)))
215            .build(context)
216    }
217
218    /// Builds the PR input screen with title, input field, and keyboard.
219    fn build_pr_input_screen(&mut self, context: &mut Context) {
220        let dpi = CURRENT_DEVICE.dpi;
221        let (width, height) = CURRENT_DEVICE.dims;
222
223        self.children.clear();
224        self.status_label_index = None;
225        self.progress_bar_index = None;
226        self.keyboard_index = None;
227
228        self.children.push(Box::new(Filler::new(
229            rect![0, 0, width as i32, height as i32],
230            WHITE,
231        )));
232
233        let font = font_from_style(&mut context.fonts, &NORMAL_STYLE, dpi);
234        let x_height = font.x_heights.0 as i32;
235        let padding = font.em() as i32;
236
237        let dialog_width = scale_by_dpi(width as f32, dpi) as i32;
238        let dialog_height = scale_by_dpi(BIG_BAR_HEIGHT, dpi) as i32;
239        let dx = (width as i32 - dialog_width) / 2;
240        let dy = (height as i32) / 3 - dialog_height / 2;
241        let rect = rect![dx, dy, dx + dialog_width, dy + dialog_height];
242
243        let title_rect = rect![
244            rect.min.x + padding,
245            rect.min.y + padding,
246            rect.max.x - padding,
247            rect.min.y + padding + 3 * x_height
248        ];
249        let title = Label::new(
250            title_rect,
251            "Download Build from PR".to_string(),
252            Align::Center,
253        );
254        self.children.push(Box::new(title));
255
256        let input_rect = rect![
257            rect.min.x + 2 * padding,
258            rect.min.y + padding + 4 * x_height,
259            rect.max.x - 2 * padding,
260            rect.min.y + padding + 8 * x_height
261        ];
262        let input = InputField::new(input_rect, ViewId::Ota(OtaViewId::PrInput));
263        self.children.push(Box::new(input));
264
265        let screen_rect = rect![0, 0, width as i32, height as i32];
266        let keyboard = ToggleableKeyboard::new(screen_rect, true);
267        self.children.push(Box::new(keyboard));
268        self.keyboard_index = Some(self.children.len() - 1);
269
270        self.rect = rect![0, 0, width as i32, height as i32];
271    }
272
273    /// Builds the full-screen progress screen shown during download/deployment.
274    ///
275    /// Clears all existing children and adds:
276    /// 1. A white full-screen [`Filler`] background
277    /// 2. A centered [`Label`] with the given status text
278    /// 3. A centered [`ProgressBar`] below the label
279    ///
280    /// The indices of the label and progress bar are stored so they can be
281    /// updated incrementally as progress events arrive.
282    fn build_progress_screen(&mut self, status: &str, context: &mut Context) {
283        let dpi = CURRENT_DEVICE.dpi;
284        let (width, height) = CURRENT_DEVICE.dims;
285
286        self.children.clear();
287        self.status_label_index = None;
288        self.progress_bar_index = None;
289        self.keyboard_index = None;
290
291        self.children.push(Box::new(Filler::new(
292            rect![0, 0, width as i32, height as i32],
293            WHITE,
294        )));
295
296        let font = font_from_style(&mut context.fonts, &NORMAL_STYLE, dpi);
297        let label_height = font.x_heights.0 as i32 * 3;
298        let bar_height = scale_by_dpi(40.0, dpi) as i32;
299        let bar_width = (width as f32 * 0.6) as i32;
300        let center_y = height as i32 / 2;
301        let gap = scale_by_dpi(24.0, dpi) as i32;
302
303        let label_rect = rect![
304            0,
305            center_y - label_height - gap / 2,
306            width as i32,
307            center_y - gap / 2
308        ];
309        self.children.push(Box::new(Label::new(
310            label_rect,
311            status.to_string(),
312            Align::Center,
313        )));
314        self.status_label_index = Some(self.children.len() - 1);
315
316        let bar_x = (width as i32 - bar_width) / 2;
317        let bar_rect = rect![
318            bar_x,
319            center_y + gap / 2,
320            bar_x + bar_width,
321            center_y + gap / 2 + bar_height
322        ];
323        self.children.push(Box::new(ProgressBar::new(bar_rect, 0)));
324        self.progress_bar_index = Some(self.children.len() - 1);
325
326        self.rect = rect![0, 0, width as i32, height as i32];
327    }
328
329    /// Toggles keyboard visibility based on focus state.
330    fn toggle_keyboard(
331        &mut self,
332        visible: bool,
333        hub: &Hub,
334        rq: &mut RenderQueue,
335        context: &mut Context,
336    ) {
337        if let Some(idx) = self.keyboard_index {
338            if let Some(keyboard) = self.children.get_mut(idx) {
339                if let Some(kb) = keyboard.downcast_mut::<ToggleableKeyboard>() {
340                    kb.set_visible(visible, hub, rq, context);
341                }
342            }
343        }
344    }
345
346    /// Handles submission of PR number from input field.
347    ///
348    /// Validates the input, transitions to the progress screen, and initiates
349    /// the download. The view stays alive so it can receive progress events and
350    /// handle token-invalid errors.
351    fn handle_pr_submission(
352        &mut self,
353        text: &str,
354        hub: &Hub,
355        rq: &mut RenderQueue,
356        context: &mut Context,
357    ) {
358        if let Ok(pr_number) = text.trim().parse::<u32>() {
359            self.pending_download = Some(PendingDownload::Pr(pr_number));
360            self.build_progress_screen(&format!("Downloading PR #{} build…", pr_number), context);
361            rq.add(RenderData::new(self.id, self.rect, UpdateMode::Full));
362            self.start_pr_download(pr_number, hub);
363        } else {
364            hub.send(Event::Notification(NotificationEvent::Show(
365                "Invalid PR number".to_string(),
366            )))
367            .ok();
368        }
369    }
370
371    /// Handles tap gesture outside the dialog and keyboard areas.
372    ///
373    /// Closes the view when user taps outside to dismiss.
374    ///
375    /// # Arguments
376    ///
377    /// * `tap_position` - The position where the tap occurred
378    /// * `context` - Application context containing keyboard rectangle
379    /// * `hub` - Event hub for sending close event
380    fn handle_outside_tap(&self, tap_position: crate::geom::Point, context: &Context, hub: &Hub) {
381        if !self.rect.includes(tap_position)
382            && !context.kb_rect.includes(tap_position)
383            && !context.kb_rect.is_empty()
384        {
385            hub.send(Event::Close(self.view_id)).ok();
386        }
387    }
388
389    /// Checks that a GitHub token is available.
390    ///
391    /// Returns `true` if a token is present and the caller may proceed.
392    /// If no token is found, pushes a [`DeviceAuthView`] child to guide the
393    /// user through device flow authentication and returns `false`.
394    fn require_github_token(
395        &mut self,
396        pending: PendingDownload,
397        hub: &Hub,
398        rq: &mut RenderQueue,
399        context: &mut Context,
400    ) -> bool {
401        if self.github_token.is_some() {
402            return true;
403        }
404
405        tracing::info!("No GitHub token found, starting device flow");
406        self.pending_download = Some(pending);
407        let auth_view = DeviceAuthView::new(hub, context);
408        self.children.push(Box::new(auth_view) as Box<dyn View>);
409        rq.add(RenderData::new(self.id, self.rect, UpdateMode::Gui));
410        false
411    }
412
413    /// Initiates the PR artifact download in a background thread.
414    ///
415    /// Sends [`Event::OtaDownloadProgress`] during the download. On success,
416    /// updates the status label to "Rebooting…" and sends
417    /// [`Event::Select`] with [`EntryId::Reboot`] to trigger an automatic reboot.
418    /// On a 401 response, sends [`Event::Github`] with [`GithubEvent::TokenInvalid`] without closing
419    /// the view so re-authentication can proceed.
420    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, hub)))]
421    fn start_pr_download(&mut self, pr_number: u32, hub: &Hub) {
422        let Some(github_token) = self.github_token.clone() else {
423            tracing::error!("GitHub token is missing when starting download, this code path should be unreachable due to prior validation");
424            return;
425        };
426
427        let hub2 = hub.clone();
428        let parent_span = tracing::Span::current();
429        let ota_view_id = self.view_id;
430
431        thread::spawn(move || {
432            let _span =
433                tracing::info_span!(parent: &parent_span, "pr_download_async", pr_number).entered();
434            let github = match GithubClient::new(Some(github_token)) {
435                Ok(c) => c,
436                Err(e) => {
437                    error!(error = %e, "Failed to create GitHub client");
438                    hub2.send(Event::Close(ota_view_id)).ok();
439                    hub2.send(Event::Notification(NotificationEvent::Show(format!(
440                        "Failed to create client: {}",
441                        e
442                    ))))
443                    .ok();
444                    return;
445                }
446            };
447            let client = OtaClient::new(github);
448
449            hub2.send(Event::OtaDownloadProgress {
450                label: format!("Downloading PR #{} build… 0%", pr_number),
451                percent: 0,
452            })
453            .ok();
454
455            let download_result = client.download_pr_artifact(pr_number, |ota_progress| {
456                if let OtaProgress::DownloadingArtifact { downloaded, total } = ota_progress {
457                    let percent = (downloaded as f32 / total as f32 * 100.0) as u8;
458                    hub2.send(Event::OtaDownloadProgress {
459                        label: format!("Downloading PR #{} build… {}%", pr_number, percent),
460                        percent,
461                    })
462                    .ok();
463                }
464            });
465
466            match download_result {
467                Ok(zip_path) => {
468                    info!(pr_number, "Download completed, starting extraction");
469                    match client.extract_and_deploy(zip_path) {
470                        Ok(_) => {
471                            hub2.send(Event::OtaDownloadProgress {
472                                label: "Installing and rebooting…".to_string(),
473                                percent: 100,
474                            })
475                            .ok();
476                            send_reboot_after_delay(hub2.clone());
477                        }
478                        Err(e) => {
479                            error!(error = %e, "Deployment failed");
480                            hub2.send(Event::Close(ota_view_id)).ok();
481                            hub2.send(Event::Notification(NotificationEvent::Show(format!(
482                                "Deployment failed: {}",
483                                e
484                            ))))
485                            .ok();
486                        }
487                    }
488                }
489                Err(OtaError::Unauthorized) | Err(OtaError::InsufficientScopes(_)) => {
490                    tracing::warn!(pr_number, "GitHub token rejected — triggering re-auth");
491                    hub2.send(Event::Github(GithubEvent::TokenInvalid)).ok();
492                }
493                Err(e) => {
494                    error!(error = %e, "PR download failed");
495                    hub2.send(Event::Close(ota_view_id)).ok();
496                    hub2.send(Event::Notification(NotificationEvent::Show(format!(
497                        "Download failed: {}",
498                        e
499                    ))))
500                    .ok();
501                }
502            }
503        });
504    }
505
506    /// Initiates the default branch download in a background thread.
507    ///
508    /// Sends [`Event::OtaDownloadProgress`] during the download. On success,
509    /// updates the status label to "Rebooting…" and sends
510    /// [`Event::Select`] with [`EntryId::Reboot`] to trigger an automatic reboot.
511    /// On a 401 response, sends [`Event::Github`] with [`GithubEvent::TokenInvalid`] without closing
512    /// the view so re-authentication can proceed.
513    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, hub)))]
514    fn start_default_branch_download(&mut self, hub: &Hub) {
515        let Some(github_token) = self.github_token.clone() else {
516            tracing::error!("GitHub token is missing when starting download, this code path should be unreachable due to prior validation");
517            return;
518        };
519
520        let hub2 = hub.clone();
521        let parent_span = tracing::Span::current();
522        let ota_view_id = self.view_id;
523
524        thread::spawn(move || {
525            let _span = tracing::info_span!(parent: &parent_span, "default_branch_download_async")
526                .entered();
527            let github = match GithubClient::new(Some(github_token)) {
528                Ok(c) => c,
529                Err(e) => {
530                    error!(error = %e, "Failed to create GitHub client");
531                    hub2.send(Event::Close(ota_view_id)).ok();
532                    hub2.send(Event::Notification(NotificationEvent::Show(format!(
533                        "Failed to create client: {}",
534                        e
535                    ))))
536                    .ok();
537                    return;
538                }
539            };
540            let client = OtaClient::new(github);
541
542            hub2.send(Event::OtaDownloadProgress {
543                label: "Downloading main branch build… 0%".to_string(),
544                percent: 0,
545            })
546            .ok();
547
548            let download_result = client.download_default_branch_artifact(|ota_progress| {
549                if let OtaProgress::DownloadingArtifact { downloaded, total } = ota_progress {
550                    let percent = (downloaded as f32 / total as f32 * 100.0) as u8;
551                    hub2.send(Event::OtaDownloadProgress {
552                        label: format!("Downloading main branch build… {}%", percent),
553                        percent,
554                    })
555                    .ok();
556                }
557            });
558
559            match download_result {
560                Ok(zip_path) => {
561                    info!("Main branch download completed, starting extraction");
562                    match client.extract_and_deploy(zip_path) {
563                        Ok(_) => {
564                            hub2.send(Event::OtaDownloadProgress {
565                                label: "Installing and rebooting…".to_string(),
566                                percent: 100,
567                            })
568                            .ok();
569                            send_reboot_after_delay(hub2.clone());
570                        }
571                        Err(e) => {
572                            error!(error = %e, "Deployment failed");
573                            hub2.send(Event::Close(ota_view_id)).ok();
574                            hub2.send(Event::Notification(NotificationEvent::Show(format!(
575                                "Deployment failed: {}",
576                                e
577                            ))))
578                            .ok();
579                        }
580                    }
581                }
582                Err(OtaError::Unauthorized) | Err(OtaError::InsufficientScopes(_)) => {
583                    tracing::warn!("GitHub token rejected — triggering re-auth");
584                    hub2.send(Event::Github(GithubEvent::TokenInvalid)).ok();
585                }
586                Err(e) => {
587                    error!(error = %e, "Main branch download failed");
588                    hub2.send(Event::Close(ota_view_id)).ok();
589                    hub2.send(Event::Notification(NotificationEvent::Show(format!(
590                        "Download failed: {}",
591                        e
592                    ))))
593                    .ok();
594                }
595            }
596        });
597    }
598
599    /// Initiates the stable release download in a background thread.
600    ///
601    /// Sends [`Event::OtaDownloadProgress`] during the download. On success,
602    /// updates the status label to "Rebooting…" and sends
603    /// [`Event::Select`] with [`EntryId::Reboot`] to trigger an automatic reboot.
604    /// On a 401 response, sends [`Event::Github`] with [`GithubEvent::TokenInvalid`] without closing
605    /// the view so re-authentication can proceed.
606    ///
607    /// GitHub authentication is not required for this operation.
608    ///
609    /// # Arguments
610    ///
611    /// * `hub` - Event hub for sending notifications and status updates
612    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, hub)))]
613    fn start_stable_release_download(&mut self, hub: &Hub) {
614        let github_token = self.github_token.clone();
615        let hub2 = hub.clone();
616        let parent_span = tracing::Span::current();
617        let ota_view_id = self.view_id;
618
619        thread::spawn(move || {
620            let _span = tracing::info_span!(parent: &parent_span, "stable_release_download_async")
621                .entered();
622            let github = match GithubClient::new(github_token) {
623                Ok(c) => c,
624                Err(e) => {
625                    error!(error = %e, "Failed to create GitHub client");
626                    hub2.send(Event::Close(ota_view_id)).ok();
627                    hub2.send(Event::Notification(NotificationEvent::Show(format!(
628                        "Failed to create client: {}",
629                        e
630                    ))))
631                    .ok();
632                    return;
633                }
634            };
635            let client = OtaClient::new(github);
636
637            hub2.send(Event::OtaDownloadProgress {
638                label: "Downloading stable release… 0%".to_string(),
639                percent: 0,
640            })
641            .ok();
642
643            let download_result = client.download_stable_release_artifact(|ota_progress| {
644                if let OtaProgress::DownloadingArtifact { downloaded, total } = ota_progress {
645                    let percent = (downloaded as f32 / total as f32 * 100.0) as u8;
646                    hub2.send(Event::OtaDownloadProgress {
647                        label: format!("Downloading stable release… {}%", percent),
648                        percent,
649                    })
650                    .ok();
651                }
652            });
653
654            match download_result {
655                Ok(asset_path) => {
656                    info!("Stable release download completed, deploying update");
657                    match client.deploy(asset_path) {
658                        Ok(_) => {
659                            hub2.send(Event::OtaDownloadProgress {
660                                label: "Installing and rebooting…".to_string(),
661                                percent: 100,
662                            })
663                            .ok();
664                            send_reboot_after_delay(hub2.clone());
665                        }
666                        Err(e) => {
667                            error!(error = %e, "Deployment failed");
668                            hub2.send(Event::Close(ota_view_id)).ok();
669                            hub2.send(Event::Notification(NotificationEvent::Show(format!(
670                                "Deployment failed: {}",
671                                e
672                            ))))
673                            .ok();
674                        }
675                    }
676                }
677                Err(OtaError::Unauthorized) | Err(OtaError::InsufficientScopes(_)) => {
678                    tracing::warn!("GitHub token rejected on stable release — triggering re-auth");
679                    hub2.send(Event::Github(GithubEvent::TokenInvalid)).ok();
680                }
681                Err(e) => {
682                    error!(error = %e, "Stable release download failed");
683                    hub2.send(Event::Close(ota_view_id)).ok();
684                    hub2.send(Event::Notification(NotificationEvent::Show(format!(
685                        "Download failed: {}",
686                        e
687                    ))))
688                    .ok();
689                }
690            }
691        });
692    }
693}
694
695/// Spawns a thread that sleeps for 1 second then sends `Event::Select(EntryId::Reboot)`.
696///
697/// The delay gives the render loop time to process the final
698/// `OtaDownloadProgress` label update before the event loop exits.
699fn send_reboot_after_delay(hub: Hub) {
700    thread::spawn(move || {
701        thread::sleep(std::time::Duration::from_secs(1));
702        hub.send(Event::Select(EntryId::Reboot)).ok();
703    });
704}
705
706impl OtaView {
707    #[inline]
708    fn on_select_default_branch(
709        &mut self,
710        hub: &Hub,
711        rq: &mut RenderQueue,
712        context: &mut Context,
713    ) -> bool {
714        if !self.require_github_token(PendingDownload::DefaultBranch, hub, rq, context) {
715            return true;
716        }
717        self.pending_download = Some(PendingDownload::DefaultBranch);
718        self.build_progress_screen("Downloading main branch build… 0%", context);
719        rq.add(RenderData::new(self.id, self.rect, UpdateMode::Full));
720        self.start_default_branch_download(hub);
721        true
722    }
723
724    #[inline]
725    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, hub)))]
726    fn on_select_stable_release(&mut self, hub: &Hub) -> bool {
727        let github_token = self.github_token.clone();
728        let ota_view_id = self.view_id;
729
730        let github = match GithubClient::new(github_token) {
731            Ok(c) => c,
732            Err(e) => {
733                tracing::error!(error = %e, "Failed to create GitHub client");
734                hub.send(Event::Close(ota_view_id)).ok();
735                hub.send(Event::Notification(NotificationEvent::Show(format!(
736                    "Failed to create client: {}",
737                    e
738                ))))
739                .ok();
740                return true;
741            }
742        };
743
744        let client = OtaClient::new(github);
745        let remote_version = match client.fetch_latest_release_version() {
746            Ok(version) => version,
747            Err(e) => {
748                tracing::error!(error = %e, "Failed to fetch or parse latest release version");
749                hub.send(Event::Close(ota_view_id)).ok();
750                hub.send(Event::Notification(NotificationEvent::Show(format!(
751                    "Failed to check for updates: {}",
752                    e
753                ))))
754                .ok();
755                return true;
756            }
757        };
758
759        let current_version = get_current_version();
760
761        tracing::info!(
762            current_version = %current_version,
763            remote_version = %remote_version,
764            "Comparing versions"
765        );
766
767        match current_version.compare(&remote_version) {
768            Ok(VersionComparison::Equal) => {
769                tracing::info!("Current version equals remote version - already latest");
770                hub.send(Event::Close(ota_view_id)).ok();
771                hub.send(Event::Notification(NotificationEvent::Show(
772                    "You already have the latest version".to_string(),
773                )))
774                .ok();
775            }
776            Ok(VersionComparison::Newer) => {
777                tracing::info!("Current version is newer than remote version");
778                hub.send(Event::Close(ota_view_id)).ok();
779                hub.send(Event::Notification(NotificationEvent::Show(
780                    "Your version is newer than the latest release".to_string(),
781                )))
782                .ok();
783            }
784            Ok(VersionComparison::Older) => {
785                tracing::info!("Remote version is newer - proceeding with download");
786                hub.send(Event::StartStableReleaseDownload).ok();
787            }
788            Ok(VersionComparison::Incomparable) => {
789                tracing::warn!("Cannot compare versions - divergent branches");
790                hub.send(Event::Close(ota_view_id)).ok();
791                hub.send(Event::Notification(NotificationEvent::Show(
792                    "Cannot compare versions - divergent branches".to_string(),
793                )))
794                .ok();
795            }
796            Err(e) => {
797                tracing::error!(error = %e, "Version comparison error");
798                hub.send(Event::Close(ota_view_id)).ok();
799                hub.send(Event::Notification(NotificationEvent::Show(format!(
800                    "Version comparison error: {}",
801                    e
802                ))))
803                .ok();
804            }
805        }
806
807        true
808    }
809
810    #[inline]
811    fn on_show_pr_input(&mut self, hub: &Hub, rq: &mut RenderQueue, context: &mut Context) -> bool {
812        if !self.require_github_token(PendingDownload::PrInputPending, hub, rq, context) {
813            return true;
814        }
815        self.build_pr_input_screen(context);
816        rq.add(RenderData::new(self.id, self.rect, UpdateMode::Gui));
817        self.toggle_keyboard(true, hub, rq, context);
818        hub.send(Event::Focus(Some(ViewId::Ota(OtaViewId::PrInput))))
819            .ok();
820        true
821    }
822
823    #[inline]
824    fn on_download_progress(&mut self, label: &str, percent: u8, rq: &mut RenderQueue) -> bool {
825        if let Some(idx) = self.status_label_index {
826            if let Some(child) = self.children.get_mut(idx) {
827                if let Some(lbl) = child.downcast_mut::<Label>() {
828                    lbl.update(label, rq);
829                }
830            }
831        }
832
833        if percent == 100 {
834            if let Some(idx) = self.progress_bar_index.take() {
835                let bar_rect = *self.children[idx].rect();
836                self.children.remove(idx);
837                rq.add(RenderData::expose(bar_rect, UpdateMode::Gui));
838            }
839        } else if let Some(idx) = self.progress_bar_index {
840            if let Some(child) = self.children.get_mut(idx) {
841                if let Some(bar) = child.downcast_mut::<ProgressBar>() {
842                    bar.update(percent, rq);
843                }
844            }
845        }
846
847        true
848    }
849
850    #[inline]
851    fn on_device_auth_complete(
852        &mut self,
853        token: &secrecy::SecretString,
854        hub: &Hub,
855        rq: &mut RenderQueue,
856        context: &mut Context,
857    ) -> bool {
858        tracing::info!("Device auth complete, saving token");
859
860        if let Err(e) = device_flow::save_token(token) {
861            tracing::error!(error = %e, "Failed to save GitHub token");
862        }
863
864        self.github_token = Some(token.clone());
865
866        match self.pending_download.take() {
867            Some(PendingDownload::DefaultBranch) => {
868                self.build_progress_screen("Downloading main branch build… 0%", context);
869                rq.add(RenderData::new(self.id, self.rect, UpdateMode::Full));
870                self.start_default_branch_download(hub);
871            }
872            Some(PendingDownload::PrInputPending) => {
873                self.build_pr_input_screen(context);
874                rq.add(RenderData::new(self.id, self.rect, UpdateMode::Gui));
875                self.toggle_keyboard(true, hub, rq, context);
876                hub.send(Event::Focus(Some(ViewId::Ota(OtaViewId::PrInput))))
877                    .ok();
878            }
879            Some(PendingDownload::Pr(pr_number)) => {
880                self.build_progress_screen(
881                    &format!("Downloading PR #{} build… 0%", pr_number),
882                    context,
883                );
884                rq.add(RenderData::new(self.id, self.rect, UpdateMode::Full));
885                self.start_pr_download(pr_number, hub);
886            }
887            None => {}
888        }
889
890        true
891    }
892
893    #[inline]
894    fn on_token_invalid(&mut self, hub: &Hub, rq: &mut RenderQueue, context: &mut Context) -> bool {
895        tracing::warn!("Saved GitHub token is invalid — clearing and re-authenticating");
896
897        if let Err(e) = device_flow::delete_token() {
898            tracing::error!(error = %e, "Failed to delete stale token");
899        }
900
901        self.github_token = None;
902
903        let auth_view = DeviceAuthView::new(hub, context);
904        self.children.push(Box::new(auth_view) as Box<dyn View>);
905        rq.add(RenderData::new(self.id, self.rect, UpdateMode::Gui));
906        true
907    }
908
909    #[inline]
910    fn on_device_auth_expired(&mut self, hub: &Hub) -> bool {
911        tracing::warn!("Device flow code expired");
912        self.pending_download = None;
913        hub.send(Event::Notification(NotificationEvent::Show(
914            "GitHub authorization timed out. Please try again.".to_string(),
915        )))
916        .ok();
917        hub.send(Event::Close(self.view_id)).ok();
918        true
919    }
920
921    #[inline]
922    fn on_device_auth_error(&mut self, msg: &str, hub: &Hub) -> bool {
923        tracing::error!(error = %msg, "Device flow error");
924        self.pending_download = None;
925        hub.send(Event::Notification(NotificationEvent::Show(format!(
926            "GitHub auth error: {}",
927            msg
928        ))))
929        .ok();
930        hub.send(Event::Close(self.view_id)).ok();
931        true
932    }
933}
934
935impl View for OtaView {
936    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, hub, _bus, rq, context), fields(event = ?evt), ret(level=tracing::Level::TRACE)))]
937    fn handle_event(
938        &mut self,
939        evt: &Event,
940        hub: &Hub,
941        _bus: &mut Bus,
942        rq: &mut RenderQueue,
943        context: &mut Context,
944    ) -> bool {
945        match evt {
946            Event::Select(EntryId::Ota(OtaEntryId::DefaultBranch)) => {
947                self.on_select_default_branch(hub, rq, context)
948            }
949            Event::Select(EntryId::Ota(OtaEntryId::StableRelease)) => {
950                self.on_select_stable_release(hub)
951            }
952            Event::Show(ViewId::Ota(OtaViewId::PrInput)) => self.on_show_pr_input(hub, rq, context),
953            Event::Focus(None) => {
954                self.toggle_keyboard(false, hub, rq, context);
955                true
956            }
957            Event::Focus(Some(ViewId::Ota(_))) => true,
958            Event::Submit(ViewId::Ota(OtaViewId::PrInput), ref text) => {
959                self.toggle_keyboard(false, hub, rq, context);
960                let text = text.clone();
961                self.handle_pr_submission(&text, hub, rq, context);
962                true
963            }
964            Event::Gesture(GestureEvent::Tap(center)) => {
965                self.handle_outside_tap(*center, context, hub);
966                true
967            }
968            Event::OtaDownloadProgress { label, percent } => {
969                self.on_download_progress(label, *percent, rq)
970            }
971            Event::Github(GithubEvent::DeviceAuthComplete(ref token)) => {
972                self.on_device_auth_complete(token, hub, rq, context)
973            }
974            Event::Github(GithubEvent::TokenInvalid) => self.on_token_invalid(hub, rq, context),
975            Event::Github(GithubEvent::DeviceAuthExpired) => self.on_device_auth_expired(hub),
976            Event::Github(GithubEvent::DeviceAuthError(ref msg)) => {
977                self.on_device_auth_error(msg, hub)
978            }
979            Event::StartStableReleaseDownload => {
980                self.build_progress_screen("Downloading stable release… 0%", context);
981                rq.add(RenderData::new(self.id, self.rect, UpdateMode::Full));
982                self.start_stable_release_download(hub);
983                true
984            }
985            _ => false,
986        }
987    }
988
989    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, _fb, _fonts, _rect), fields(rect = ?_rect)))]
990    fn render(&self, _fb: &mut dyn Framebuffer, _rect: Rectangle, _fonts: &mut Fonts) {}
991
992    fn rect(&self) -> &Rectangle {
993        &self.rect
994    }
995
996    fn rect_mut(&mut self) -> &mut Rectangle {
997        &mut self.rect
998    }
999
1000    fn children(&self) -> &Vec<Box<dyn View>> {
1001        &self.children
1002    }
1003
1004    fn children_mut(&mut self) -> &mut Vec<Box<dyn View>> {
1005        &mut self.children
1006    }
1007
1008    fn id(&self) -> Id {
1009        self.id
1010    }
1011
1012    fn view_id(&self) -> Option<ViewId> {
1013        Some(self.view_id)
1014    }
1015
1016    fn resize(
1017        &mut self,
1018        _rect: Rectangle,
1019        _hub: &Hub,
1020        _rq: &mut RenderQueue,
1021        _context: &mut Context,
1022    ) {
1023    }
1024}
1025
1026#[cfg(test)]
1027mod tests {
1028    use super::*;
1029    use crate::context::test_helpers::create_test_context;
1030    use crate::view::handle_event;
1031    use crate::view::keyboard::Keyboard;
1032    use std::collections::VecDeque;
1033    use std::sync::mpsc::channel;
1034
1035    fn create_ota_view(context: &mut Context) -> OtaView {
1036        OtaView::new(context)
1037    }
1038
1039    /// A minimal parent view that mimics Home/Reader keyboard behavior.
1040    ///
1041    /// When it receives `Event::Focus(Some(_))`, it inserts a Keyboard
1042    /// child — exactly like Home and Reader do. This lets us assert that
1043    /// the OtaView prevents the focus event from reaching the parent.
1044    struct FakeParentView {
1045        id: Id,
1046        rect: Rectangle,
1047        children: Vec<Box<dyn View>>,
1048    }
1049
1050    impl FakeParentView {
1051        fn new(rect: Rectangle) -> Self {
1052            FakeParentView {
1053                id: ID_FEEDER.next(),
1054                rect,
1055                children: Vec::new(),
1056            }
1057        }
1058
1059        fn has_keyboard(&self) -> bool {
1060            self.children
1061                .iter()
1062                .any(|c| c.downcast_ref::<Keyboard>().is_some())
1063        }
1064    }
1065
1066    impl View for FakeParentView {
1067        fn handle_event(
1068            &mut self,
1069            evt: &Event,
1070            _hub: &Hub,
1071            _bus: &mut Bus,
1072            _rq: &mut RenderQueue,
1073            context: &mut Context,
1074        ) -> bool {
1075            match *evt {
1076                Event::Focus(Some(_)) => {
1077                    let mut kb_rect = rect![
1078                        self.rect.min.x,
1079                        self.rect.max.y - 300,
1080                        self.rect.max.x,
1081                        self.rect.max.y - 66
1082                    ];
1083                    let keyboard = Keyboard::new(&mut kb_rect, false, context);
1084                    self.children.push(Box::new(keyboard) as Box<dyn View>);
1085                    true
1086                }
1087                _ => false,
1088            }
1089        }
1090
1091        fn render(&self, _fb: &mut dyn Framebuffer, _rect: Rectangle, _fonts: &mut Fonts) {}
1092
1093        fn rect(&self) -> &Rectangle {
1094            &self.rect
1095        }
1096        fn rect_mut(&mut self) -> &mut Rectangle {
1097            &mut self.rect
1098        }
1099        fn children(&self) -> &Vec<Box<dyn View>> {
1100            &self.children
1101        }
1102        fn children_mut(&mut self) -> &mut Vec<Box<dyn View>> {
1103            &mut self.children
1104        }
1105        fn id(&self) -> Id {
1106            self.id
1107        }
1108    }
1109
1110    #[test]
1111    fn test_ota_view_consumes_own_focus_event() {
1112        let mut context = create_test_context();
1113        let mut ota = create_ota_view(&mut context);
1114        let (hub, _rx) = channel();
1115        let mut bus: Bus = VecDeque::new();
1116        let mut rq = RenderQueue::new();
1117
1118        let focus_evt = Event::Focus(Some(ViewId::Ota(OtaViewId::PrInput)));
1119        let handled = ota.handle_event(&focus_evt, &hub, &mut bus, &mut rq, &mut context);
1120
1121        assert!(
1122            handled,
1123            "OtaView must consume focus events for its own ViewIds"
1124        );
1125        assert!(bus.is_empty(), "Focus event must not leak to parent bus");
1126    }
1127
1128    #[test]
1129    fn test_ota_view_does_not_consume_foreign_focus_event() {
1130        let mut context = create_test_context();
1131        let mut ota = create_ota_view(&mut context);
1132        let (hub, _rx) = channel();
1133        let mut bus: Bus = VecDeque::new();
1134        let mut rq = RenderQueue::new();
1135
1136        let focus_evt = Event::Focus(Some(ViewId::HomeSearchInput));
1137        let handled = ota.handle_event(&focus_evt, &hub, &mut bus, &mut rq, &mut context);
1138
1139        assert!(
1140            !handled,
1141            "OtaView must not consume focus events for other ViewIds"
1142        );
1143    }
1144
1145    /// Simulates the full event dispatch chain when OtaView shows the PR
1146    /// input screen.
1147    ///
1148    /// The `Event::Show` handler sends `Event::Focus(Some(Ota(PrInput)))`
1149    /// to the hub. We drain the hub and dispatch each event through the
1150    /// view tree — just like the main loop does — and assert that the
1151    /// parent never inserts a keyboard child.
1152    #[test]
1153    fn test_parent_keyboard_not_shown_when_ota_focuses_input() {
1154        crate::crypto::init_crypto_provider();
1155
1156        let mut context = create_test_context();
1157        context.load_keyboard_layouts();
1158        context.load_dictionaries();
1159
1160        let (hub, rx) = channel();
1161        let mut bus: Bus = VecDeque::new();
1162        let mut rq = RenderQueue::new();
1163
1164        let mut parent = FakeParentView::new(rect![0, 0, 600, 800]);
1165        let ota = create_ota_view(&mut context);
1166        parent.children.push(Box::new(ota) as Box<dyn View>);
1167
1168        assert!(
1169            !parent.has_keyboard(),
1170            "Parent must not have keyboard before focus"
1171        );
1172
1173        let show_evt = Event::Show(ViewId::Ota(OtaViewId::PrInput));
1174        handle_event(
1175            &mut parent,
1176            &show_evt,
1177            &hub,
1178            &mut bus,
1179            &mut rq,
1180            &mut context,
1181        );
1182
1183        while let Ok(evt) = rx.try_recv() {
1184            handle_event(&mut parent, &evt, &hub, &mut bus, &mut rq, &mut context);
1185        }
1186
1187        assert!(
1188            !parent.has_keyboard(),
1189            "Parent keyboard must not be shown — OtaView should consume its own focus event"
1190        );
1191    }
1192}