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, 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::fl;
16use crate::font::{font_from_style, Fonts, NORMAL_STYLE};
17use crate::framebuffer::Framebuffer;
18use crate::geom::Rectangle;
19use crate::gesture::GestureEvent;
20use crate::github::device_flow;
21use crate::github::GithubClient;
22use crate::ota::{clean_bundled_files, OtaClient, OtaError, OtaProgress};
23use crate::unit::scale_by_dpi;
24use crate::version::{get_current_version, VersionComparison};
25use crate::view::filler::Filler;
26use crate::view::github::GithubEvent;
27use crate::view::BIG_BAR_HEIGHT;
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!("GitHub token is missing when starting download, this code path should be unreachable due to prior validation");
425            return;
426        };
427
428        let hub2 = hub.clone();
429        let parent_span = tracing::Span::current();
430        let ota_view_id = self.view_id;
431
432        thread::spawn(move || {
433            let _span =
434                tracing::info_span!(parent: &parent_span, "pr_download_async", pr_number).entered();
435            let github = match GithubClient::new(Some(github_token)) {
436                Ok(c) => c,
437                Err(e) => {
438                    error!(error = %e, "Failed to create GitHub client");
439                    hub2.send(Event::Close(ota_view_id)).ok();
440                    hub2.send(Event::Notification(NotificationEvent::Show(format!(
441                        "Failed to create client: {}",
442                        e
443                    ))))
444                    .ok();
445                    return;
446                }
447            };
448            let client = OtaClient::new(github, CURRENT_DEVICE.tmp_dir());
449
450            hub2.send(Event::OtaDownloadProgress {
451                label: format!("Downloading PR #{} build… 0%", pr_number),
452                percent: 0,
453            })
454            .ok();
455
456            let download_result = client.download_pr_artifact(pr_number, |ota_progress| {
457                if let OtaProgress::DownloadingArtifact { downloaded, total } = ota_progress {
458                    let percent = (downloaded as f32 / total as f32 * 100.0) as u8;
459                    hub2.send(Event::OtaDownloadProgress {
460                        label: format!("Downloading PR #{} build… {}%", pr_number, percent),
461                        percent,
462                    })
463                    .ok();
464                }
465            });
466
467            match download_result {
468                Ok(zip_path) => {
469                    info!(pr_number, "Download completed, starting extraction");
470                    match client.extract_and_deploy(zip_path) {
471                        Ok(_) => {
472                            clean_installation_before_reboot();
473                            hub2.send(Event::OtaDownloadProgress {
474                                label: "Installing and rebooting…".to_string(),
475                                percent: 100,
476                            })
477                            .ok();
478                            send_reboot_after_delay(hub2.clone());
479                        }
480                        Err(e) => {
481                            error!(error = %e, "Deployment failed");
482                            hub2.send(Event::Close(ota_view_id)).ok();
483                            hub2.send(Event::Notification(NotificationEvent::Show(format!(
484                                "Deployment failed: {}",
485                                e
486                            ))))
487                            .ok();
488                        }
489                    }
490                }
491                Err(OtaError::Unauthorized) | Err(OtaError::InsufficientScopes(_)) => {
492                    tracing::warn!(pr_number, "GitHub token rejected — triggering re-auth");
493                    hub2.send(Event::Github(GithubEvent::TokenInvalid)).ok();
494                }
495                Err(e) => {
496                    error!(error = %e, "PR download failed");
497                    hub2.send(Event::Close(ota_view_id)).ok();
498                    hub2.send(Event::Notification(NotificationEvent::Show(format!(
499                        "Download failed: {}",
500                        e
501                    ))))
502                    .ok();
503                }
504            }
505        });
506    }
507
508    /// Initiates the default branch download in a background thread.
509    ///
510    /// Sends [`Event::OtaDownloadProgress`] during the download. On success,
511    /// updates the status label to "Rebooting…" and sends
512    /// [`Event::Select`] with [`EntryId::Reboot`] to trigger an automatic reboot.
513    /// On a 401 response, sends [`Event::Github`] with [`GithubEvent::TokenInvalid`] without closing
514    /// the view so re-authentication can proceed.
515    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, hub)))]
516    fn start_default_branch_download(&mut self, hub: &Hub) {
517        let Some(github_token) = self.github_token.clone() else {
518            tracing::error!("GitHub token is missing when starting download, this code path should be unreachable due to prior validation");
519            return;
520        };
521
522        let hub2 = hub.clone();
523        let parent_span = tracing::Span::current();
524        let ota_view_id = self.view_id;
525
526        thread::spawn(move || {
527            let _span = tracing::info_span!(parent: &parent_span, "default_branch_download_async")
528                .entered();
529            let github = match GithubClient::new(Some(github_token)) {
530                Ok(c) => c,
531                Err(e) => {
532                    error!(error = %e, "Failed to create GitHub client");
533                    hub2.send(Event::Close(ota_view_id)).ok();
534                    hub2.send(Event::Notification(NotificationEvent::Show(format!(
535                        "Failed to create client: {}",
536                        e
537                    ))))
538                    .ok();
539                    return;
540                }
541            };
542            let client = OtaClient::new(github, CURRENT_DEVICE.tmp_dir());
543
544            hub2.send(Event::OtaDownloadProgress {
545                label: "Downloading main branch build… 0%".to_string(),
546                percent: 0,
547            })
548            .ok();
549
550            let download_result = client.download_default_branch_artifact(|ota_progress| {
551                if let OtaProgress::DownloadingArtifact { downloaded, total } = ota_progress {
552                    let percent = (downloaded as f32 / total as f32 * 100.0) as u8;
553                    hub2.send(Event::OtaDownloadProgress {
554                        label: format!("Downloading main branch build… {}%", percent),
555                        percent,
556                    })
557                    .ok();
558                }
559            });
560
561            match download_result {
562                Ok(zip_path) => {
563                    info!("Main branch download completed, starting extraction");
564                    match client.extract_and_deploy(zip_path) {
565                        Ok(_) => {
566                            clean_installation_before_reboot();
567                            hub2.send(Event::OtaDownloadProgress {
568                                label: "Installing and rebooting…".to_string(),
569                                percent: 100,
570                            })
571                            .ok();
572                            send_reboot_after_delay(hub2.clone());
573                        }
574                        Err(e) => {
575                            error!(error = %e, "Deployment failed");
576                            hub2.send(Event::Close(ota_view_id)).ok();
577                            hub2.send(Event::Notification(NotificationEvent::Show(format!(
578                                "Deployment failed: {}",
579                                e
580                            ))))
581                            .ok();
582                        }
583                    }
584                }
585                Err(OtaError::Unauthorized) | Err(OtaError::InsufficientScopes(_)) => {
586                    tracing::warn!("GitHub token rejected — triggering re-auth");
587                    hub2.send(Event::Github(GithubEvent::TokenInvalid)).ok();
588                }
589                Err(e) => {
590                    error!(error = %e, "Main branch download failed");
591                    hub2.send(Event::Close(ota_view_id)).ok();
592                    hub2.send(Event::Notification(NotificationEvent::Show(format!(
593                        "Download failed: {}",
594                        e
595                    ))))
596                    .ok();
597                }
598            }
599        });
600    }
601
602    /// Initiates the stable release download in a background thread.
603    ///
604    /// Sends [`Event::OtaDownloadProgress`] during the download. On success,
605    /// updates the status label to "Rebooting…" and sends
606    /// [`Event::Select`] with [`EntryId::Reboot`] to trigger an automatic reboot.
607    /// On a 401 response, sends [`Event::Github`] with [`GithubEvent::TokenInvalid`] without closing
608    /// the view so re-authentication can proceed.
609    ///
610    /// GitHub authentication is not required for this operation.
611    ///
612    /// # Arguments
613    ///
614    /// * `hub` - Event hub for sending notifications and status updates
615    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, hub)))]
616    fn start_stable_release_download(&mut self, hub: &Hub) {
617        let github_token = self.github_token.clone();
618        let hub2 = hub.clone();
619        let parent_span = tracing::Span::current();
620        let ota_view_id = self.view_id;
621
622        thread::spawn(move || {
623            let _span = tracing::info_span!(parent: &parent_span, "stable_release_download_async")
624                .entered();
625            let github = match GithubClient::new(github_token) {
626                Ok(c) => c,
627                Err(e) => {
628                    error!(error = %e, "Failed to create GitHub client");
629                    hub2.send(Event::Close(ota_view_id)).ok();
630                    hub2.send(Event::Notification(NotificationEvent::Show(format!(
631                        "Failed to create client: {}",
632                        e
633                    ))))
634                    .ok();
635                    return;
636                }
637            };
638            let client = OtaClient::new(github, CURRENT_DEVICE.tmp_dir());
639
640            hub2.send(Event::OtaDownloadProgress {
641                label: "Downloading stable release… 0%".to_string(),
642                percent: 0,
643            })
644            .ok();
645
646            let download_result = client.download_stable_release_artifact(|ota_progress| {
647                if let OtaProgress::DownloadingArtifact { downloaded, total } = ota_progress {
648                    let percent = (downloaded as f32 / total as f32 * 100.0) as u8;
649                    hub2.send(Event::OtaDownloadProgress {
650                        label: format!("Downloading stable release… {}%", percent),
651                        percent,
652                    })
653                    .ok();
654                }
655            });
656
657            match download_result {
658                Ok(asset_path) => {
659                    info!("Stable release download completed, deploying update");
660                    match client.deploy(asset_path) {
661                        Ok(_) => {
662                            clean_installation_before_reboot();
663                            hub2.send(Event::OtaDownloadProgress {
664                                label: "Installing and rebooting…".to_string(),
665                                percent: 100,
666                            })
667                            .ok();
668                            send_reboot_after_delay(hub2.clone());
669                        }
670                        Err(e) => {
671                            error!(error = %e, "Deployment failed");
672                            hub2.send(Event::Close(ota_view_id)).ok();
673                            hub2.send(Event::Notification(NotificationEvent::Show(format!(
674                                "Deployment failed: {}",
675                                e
676                            ))))
677                            .ok();
678                        }
679                    }
680                }
681                Err(OtaError::Unauthorized) | Err(OtaError::InsufficientScopes(_)) => {
682                    tracing::warn!("GitHub token rejected on stable release — triggering re-auth");
683                    hub2.send(Event::Github(GithubEvent::TokenInvalid)).ok();
684                }
685                Err(e) => {
686                    error!(error = %e, "Stable release download failed");
687                    hub2.send(Event::Close(ota_view_id)).ok();
688                    hub2.send(Event::Notification(NotificationEvent::Show(format!(
689                        "Download failed: {}",
690                        e
691                    ))))
692                    .ok();
693                }
694            }
695        });
696    }
697}
698
699/// Spawns a thread that sleeps for 1 second then sends `Event::Select(EntryId::Reboot)`.
700///
701/// The delay gives the render loop time to process the final
702/// `OtaDownloadProgress` label update before the event loop exits.
703fn send_reboot_after_delay(hub: Hub) {
704    thread::spawn(move || {
705        thread::sleep(std::time::Duration::from_secs(1));
706        hub.send(Event::Select(EntryId::Reboot)).ok();
707    });
708}
709
710fn clean_installation_before_reboot() {
711    let install_dir = CURRENT_DEVICE.install_dir();
712
713    if let Err(e) = clean_bundled_files(&install_dir) {
714        tracing::warn!(path = ?install_dir, error = %e, "Failed to clean bundled OTA files");
715    }
716}
717
718impl OtaView {
719    #[inline]
720    fn on_select_default_branch(
721        &mut self,
722        hub: &Hub,
723        rq: &mut RenderQueue,
724        context: &mut Context,
725    ) -> bool {
726        if !self.require_github_token(PendingDownload::DefaultBranch, hub, rq, context) {
727            return true;
728        }
729        self.pending_download = Some(PendingDownload::DefaultBranch);
730        self.build_progress_screen("Downloading main branch build… 0%", context);
731        rq.add(RenderData::new(self.id, self.rect, UpdateMode::Full));
732        self.start_default_branch_download(hub);
733        true
734    }
735
736    #[inline]
737    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, hub)))]
738    fn on_select_stable_release(&mut self, hub: &Hub) -> bool {
739        let github_token = self.github_token.clone();
740        let ota_view_id = self.view_id;
741
742        let github = match GithubClient::new(github_token) {
743            Ok(c) => c,
744            Err(e) => {
745                tracing::error!(error = %e, "Failed to create GitHub client");
746                hub.send(Event::Close(ota_view_id)).ok();
747                hub.send(Event::Notification(NotificationEvent::Show(format!(
748                    "Failed to create client: {}",
749                    e
750                ))))
751                .ok();
752                return true;
753            }
754        };
755
756        let client = OtaClient::new(github, CURRENT_DEVICE.tmp_dir());
757        let remote_version = match client.fetch_latest_release_version() {
758            Ok(version) => version,
759            Err(e) => {
760                tracing::error!(error = %e, "Failed to fetch or parse latest release version");
761                hub.send(Event::Close(ota_view_id)).ok();
762                hub.send(Event::Notification(NotificationEvent::Show(format!(
763                    "Failed to check for updates: {}",
764                    e
765                ))))
766                .ok();
767                return true;
768            }
769        };
770
771        let current_version = get_current_version();
772
773        tracing::info!(
774            current_version = %current_version,
775            remote_version = %remote_version,
776            "Comparing versions"
777        );
778
779        match current_version.compare(&remote_version) {
780            Ok(VersionComparison::Equal) => {
781                tracing::info!("Current version equals remote version - already latest");
782                hub.send(Event::Close(ota_view_id)).ok();
783                hub.send(Event::Notification(NotificationEvent::Show(
784                    "You already have the latest version".to_string(),
785                )))
786                .ok();
787            }
788            Ok(VersionComparison::Newer) => {
789                tracing::info!("Current version is newer than remote version");
790                hub.send(Event::Close(ota_view_id)).ok();
791                hub.send(Event::Notification(NotificationEvent::Show(
792                    "Your version is newer than the latest release".to_string(),
793                )))
794                .ok();
795            }
796            Ok(VersionComparison::Older) => {
797                tracing::info!("Remote version is newer - proceeding with download");
798                hub.send(Event::StartStableReleaseDownload).ok();
799            }
800            Ok(VersionComparison::Incomparable) => {
801                tracing::warn!("Cannot compare versions - divergent branches");
802                hub.send(Event::Close(ota_view_id)).ok();
803                hub.send(Event::Notification(NotificationEvent::Show(
804                    "Cannot compare versions - divergent branches".to_string(),
805                )))
806                .ok();
807            }
808            Err(e) => {
809                tracing::error!(error = %e, "Version comparison error");
810                hub.send(Event::Close(ota_view_id)).ok();
811                hub.send(Event::Notification(NotificationEvent::Show(format!(
812                    "Version comparison error: {}",
813                    e
814                ))))
815                .ok();
816            }
817        }
818
819        true
820    }
821
822    #[inline]
823    fn on_show_pr_input(&mut self, hub: &Hub, rq: &mut RenderQueue, context: &mut Context) -> bool {
824        if !self.require_github_token(PendingDownload::PrInputPending, hub, rq, context) {
825            return true;
826        }
827        self.build_pr_input_screen(context);
828        rq.add(RenderData::new(self.id, self.rect, UpdateMode::Gui));
829        self.toggle_keyboard(true, hub, rq, context);
830        hub.send(Event::Focus(Some(ViewId::Ota(OtaViewId::PrInput))))
831            .ok();
832        true
833    }
834
835    #[inline]
836    fn on_download_progress(&mut self, label: &str, percent: u8, rq: &mut RenderQueue) -> bool {
837        if let Some(idx) = self.status_label_index {
838            if let Some(child) = self.children.get_mut(idx) {
839                if let Some(lbl) = child.downcast_mut::<Label>() {
840                    lbl.update(label, rq);
841                }
842            }
843        }
844
845        if percent == 100 {
846            if let Some(idx) = self.progress_bar_index.take() {
847                let bar_rect = *self.children[idx].rect();
848                self.children.remove(idx);
849                rq.add(RenderData::expose(bar_rect, UpdateMode::Gui));
850            }
851        } else if let Some(idx) = self.progress_bar_index {
852            if let Some(child) = self.children.get_mut(idx) {
853                if let Some(bar) = child.downcast_mut::<ProgressBar>() {
854                    bar.update(percent, rq);
855                }
856            }
857        }
858
859        true
860    }
861
862    #[inline]
863    fn on_device_auth_complete(
864        &mut self,
865        token: &secrecy::SecretString,
866        hub: &Hub,
867        rq: &mut RenderQueue,
868        context: &mut Context,
869    ) -> bool {
870        tracing::info!("Device auth complete, saving token");
871
872        if let Err(e) = device_flow::save_token(token) {
873            tracing::error!(error = %e, "Failed to save GitHub token");
874        }
875
876        self.github_token = Some(token.clone());
877
878        match self.pending_download.take() {
879            Some(PendingDownload::DefaultBranch) => {
880                self.build_progress_screen("Downloading main branch build… 0%", context);
881                rq.add(RenderData::new(self.id, self.rect, UpdateMode::Full));
882                self.start_default_branch_download(hub);
883            }
884            Some(PendingDownload::PrInputPending) => {
885                self.build_pr_input_screen(context);
886                rq.add(RenderData::new(self.id, self.rect, UpdateMode::Gui));
887                self.toggle_keyboard(true, hub, rq, context);
888                hub.send(Event::Focus(Some(ViewId::Ota(OtaViewId::PrInput))))
889                    .ok();
890            }
891            Some(PendingDownload::Pr(pr_number)) => {
892                self.build_progress_screen(
893                    &format!("Downloading PR #{} build… 0%", pr_number),
894                    context,
895                );
896                rq.add(RenderData::new(self.id, self.rect, UpdateMode::Full));
897                self.start_pr_download(pr_number, hub);
898            }
899            None => {}
900        }
901
902        true
903    }
904
905    #[inline]
906    fn on_token_invalid(&mut self, hub: &Hub, rq: &mut RenderQueue, context: &mut Context) -> bool {
907        tracing::warn!("Saved GitHub token is invalid — clearing and re-authenticating");
908
909        if let Err(e) = device_flow::delete_token() {
910            tracing::error!(error = %e, "Failed to delete stale token");
911        }
912
913        self.github_token = None;
914
915        let auth_view = DeviceAuthView::new(hub, context);
916        self.children.push(Box::new(auth_view) as Box<dyn View>);
917        rq.add(RenderData::new(self.id, self.rect, UpdateMode::Gui));
918        true
919    }
920
921    #[inline]
922    fn on_device_auth_expired(&mut self, hub: &Hub) -> bool {
923        tracing::warn!("Device flow code expired");
924        self.pending_download = None;
925        hub.send(Event::Notification(NotificationEvent::Show(
926            "GitHub authorization timed out. Please try again.".to_string(),
927        )))
928        .ok();
929        hub.send(Event::Close(self.view_id)).ok();
930        true
931    }
932
933    #[inline]
934    fn on_device_auth_error(&mut self, msg: &str, hub: &Hub) -> bool {
935        tracing::error!(error = %msg, "Device flow error");
936        self.pending_download = None;
937        hub.send(Event::Notification(NotificationEvent::Show(format!(
938            "GitHub auth error: {}",
939            msg
940        ))))
941        .ok();
942        hub.send(Event::Close(self.view_id)).ok();
943        true
944    }
945}
946
947impl View for OtaView {
948    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, hub, _bus, rq, context), fields(event = ?evt), ret(level=tracing::Level::TRACE)))]
949    fn handle_event(
950        &mut self,
951        evt: &Event,
952        hub: &Hub,
953        _bus: &mut Bus,
954        rq: &mut RenderQueue,
955        context: &mut Context,
956    ) -> bool {
957        match evt {
958            Event::Select(EntryId::Ota(OtaEntryId::DefaultBranch)) => {
959                self.on_select_default_branch(hub, rq, context)
960            }
961            Event::Select(EntryId::Ota(OtaEntryId::StableRelease)) => {
962                self.on_select_stable_release(hub)
963            }
964            Event::Show(ViewId::Ota(OtaViewId::PrInput)) => self.on_show_pr_input(hub, rq, context),
965            Event::Focus(None) => {
966                self.toggle_keyboard(false, hub, rq, context);
967                true
968            }
969            Event::Focus(Some(ViewId::Ota(_))) => true,
970            Event::Submit(ViewId::Ota(OtaViewId::PrInput), ref text) => {
971                self.toggle_keyboard(false, hub, rq, context);
972                let text = text.clone();
973                self.handle_pr_submission(&text, hub, rq, context);
974                true
975            }
976            Event::Gesture(GestureEvent::Tap(center)) => {
977                self.handle_outside_tap(*center, context, hub);
978                true
979            }
980            Event::OtaDownloadProgress { label, percent } => {
981                self.on_download_progress(label, *percent, rq)
982            }
983            Event::Github(GithubEvent::DeviceAuthComplete(ref token)) => {
984                self.on_device_auth_complete(token, hub, rq, context)
985            }
986            Event::Github(GithubEvent::TokenInvalid) => self.on_token_invalid(hub, rq, context),
987            Event::Github(GithubEvent::DeviceAuthExpired) => self.on_device_auth_expired(hub),
988            Event::Github(GithubEvent::DeviceAuthError(ref msg)) => {
989                self.on_device_auth_error(msg, hub)
990            }
991            Event::StartStableReleaseDownload => {
992                self.build_progress_screen("Downloading stable release… 0%", context);
993                rq.add(RenderData::new(self.id, self.rect, UpdateMode::Full));
994                self.start_stable_release_download(hub);
995                true
996            }
997            _ => false,
998        }
999    }
1000
1001    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, _fb, _fonts, _rect), fields(rect = ?_rect)))]
1002    fn render(&self, _fb: &mut dyn Framebuffer, _rect: Rectangle, _fonts: &mut Fonts) {}
1003
1004    fn rect(&self) -> &Rectangle {
1005        &self.rect
1006    }
1007
1008    fn rect_mut(&mut self) -> &mut Rectangle {
1009        &mut self.rect
1010    }
1011
1012    fn children(&self) -> &Vec<Box<dyn View>> {
1013        &self.children
1014    }
1015
1016    fn children_mut(&mut self) -> &mut Vec<Box<dyn View>> {
1017        &mut self.children
1018    }
1019
1020    fn id(&self) -> Id {
1021        self.id
1022    }
1023
1024    fn view_id(&self) -> Option<ViewId> {
1025        Some(self.view_id)
1026    }
1027
1028    fn resize(
1029        &mut self,
1030        _rect: Rectangle,
1031        _hub: &Hub,
1032        _rq: &mut RenderQueue,
1033        _context: &mut Context,
1034    ) {
1035    }
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040    use super::*;
1041    use crate::context::test_helpers::create_test_context;
1042    use crate::view::handle_event;
1043    use crate::view::keyboard::Keyboard;
1044    use std::collections::VecDeque;
1045    use std::sync::mpsc::channel;
1046
1047    fn create_ota_view(context: &mut Context) -> OtaView {
1048        OtaView::new(context)
1049    }
1050
1051    /// A minimal parent view that mimics Home/Reader keyboard behavior.
1052    ///
1053    /// When it receives `Event::Focus(Some(_))`, it inserts a Keyboard
1054    /// child — exactly like Home and Reader do. This lets us assert that
1055    /// the OtaView prevents the focus event from reaching the parent.
1056    struct FakeParentView {
1057        id: Id,
1058        rect: Rectangle,
1059        children: Vec<Box<dyn View>>,
1060    }
1061
1062    impl FakeParentView {
1063        fn new(rect: Rectangle) -> Self {
1064            FakeParentView {
1065                id: ID_FEEDER.next(),
1066                rect,
1067                children: Vec::new(),
1068            }
1069        }
1070
1071        fn has_keyboard(&self) -> bool {
1072            self.children
1073                .iter()
1074                .any(|c| c.downcast_ref::<Keyboard>().is_some())
1075        }
1076    }
1077
1078    impl View for FakeParentView {
1079        fn handle_event(
1080            &mut self,
1081            evt: &Event,
1082            _hub: &Hub,
1083            _bus: &mut Bus,
1084            _rq: &mut RenderQueue,
1085            context: &mut Context,
1086        ) -> bool {
1087            match *evt {
1088                Event::Focus(Some(_)) => {
1089                    let mut kb_rect = rect![
1090                        self.rect.min.x,
1091                        self.rect.max.y - 300,
1092                        self.rect.max.x,
1093                        self.rect.max.y - 66
1094                    ];
1095                    let keyboard = Keyboard::new(&mut kb_rect, false, context);
1096                    self.children.push(Box::new(keyboard) as Box<dyn View>);
1097                    true
1098                }
1099                _ => false,
1100            }
1101        }
1102
1103        fn render(&self, _fb: &mut dyn Framebuffer, _rect: Rectangle, _fonts: &mut Fonts) {}
1104
1105        fn rect(&self) -> &Rectangle {
1106            &self.rect
1107        }
1108        fn rect_mut(&mut self) -> &mut Rectangle {
1109            &mut self.rect
1110        }
1111        fn children(&self) -> &Vec<Box<dyn View>> {
1112            &self.children
1113        }
1114        fn children_mut(&mut self) -> &mut Vec<Box<dyn View>> {
1115            &mut self.children
1116        }
1117        fn id(&self) -> Id {
1118            self.id
1119        }
1120    }
1121
1122    #[test]
1123    fn test_ota_view_consumes_own_focus_event() {
1124        let mut context = create_test_context();
1125        let mut ota = create_ota_view(&mut context);
1126        let (hub, _rx) = channel();
1127        let mut bus: Bus = VecDeque::new();
1128        let mut rq = RenderQueue::new();
1129
1130        let focus_evt = Event::Focus(Some(ViewId::Ota(OtaViewId::PrInput)));
1131        let handled = ota.handle_event(&focus_evt, &hub, &mut bus, &mut rq, &mut context);
1132
1133        assert!(
1134            handled,
1135            "OtaView must consume focus events for its own ViewIds"
1136        );
1137        assert!(bus.is_empty(), "Focus event must not leak to parent bus");
1138    }
1139
1140    #[test]
1141    fn test_ota_view_does_not_consume_foreign_focus_event() {
1142        let mut context = create_test_context();
1143        let mut ota = create_ota_view(&mut context);
1144        let (hub, _rx) = channel();
1145        let mut bus: Bus = VecDeque::new();
1146        let mut rq = RenderQueue::new();
1147
1148        let focus_evt = Event::Focus(Some(ViewId::HomeSearchInput));
1149        let handled = ota.handle_event(&focus_evt, &hub, &mut bus, &mut rq, &mut context);
1150
1151        assert!(
1152            !handled,
1153            "OtaView must not consume focus events for other ViewIds"
1154        );
1155    }
1156
1157    /// Simulates the full event dispatch chain when OtaView shows the PR
1158    /// input screen.
1159    ///
1160    /// The `Event::Show` handler sends `Event::Focus(Some(Ota(PrInput)))`
1161    /// to the hub. We drain the hub and dispatch each event through the
1162    /// view tree — just like the main loop does — and assert that the
1163    /// parent never inserts a keyboard child.
1164    #[test]
1165    fn test_parent_keyboard_not_shown_when_ota_focuses_input() {
1166        crate::crypto::init_crypto_provider();
1167
1168        let mut context = create_test_context();
1169        context.load_keyboard_layouts();
1170        context.load_dictionaries();
1171
1172        let (hub, rx) = channel();
1173        let mut bus: Bus = VecDeque::new();
1174        let mut rq = RenderQueue::new();
1175
1176        let mut parent = FakeParentView::new(rect![0, 0, 600, 800]);
1177        let ota = create_ota_view(&mut context);
1178        parent.children.push(Box::new(ota) as Box<dyn View>);
1179
1180        assert!(
1181            !parent.has_keyboard(),
1182            "Parent must not have keyboard before focus"
1183        );
1184
1185        let show_evt = Event::Show(ViewId::Ota(OtaViewId::PrInput));
1186        handle_event(
1187            &mut parent,
1188            &show_evt,
1189            &hub,
1190            &mut bus,
1191            &mut rq,
1192            &mut context,
1193        );
1194
1195        while let Ok(evt) = rx.try_recv() {
1196            handle_event(&mut parent, &evt, &hub, &mut bus, &mut rq, &mut context);
1197        }
1198
1199        assert!(
1200            !parent.has_keyboard(),
1201            "Parent keyboard must not be shown — OtaView should consume its own focus event"
1202        );
1203    }
1204}