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