Skip to main content

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