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#[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#[derive(Debug, Clone)]
101enum PendingDownload {
102 DefaultBranch,
103 PrInputPending,
104 Pr(u32),
105}
106
107pub 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 status_label_index: Option<usize>,
137 progress_bar_index: Option<usize>,
139}
140
141impl OtaView {
142 #[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 #[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 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 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 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 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 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 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 #[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 #[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 #[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
699fn 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 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 #[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}