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