cadmus_core/view/
device_auth.rs

1//! Device flow authentication view for GitHub OAuth.
2//!
3//! Displays the user code and verification URL, then polls GitHub in a
4//! background thread until the user authorizes (or the code expires).
5//!
6//! On success, sends [`Event::Github`] with [`GithubEvent::DeviceAuthComplete`].
7//! On expiry, sends [`Event::Github`] with [`GithubEvent::DeviceAuthExpired`].
8//! On error, sends [`Event::Github`] with [`GithubEvent::DeviceAuthError`].
9//! On cancel, the polling thread is stopped via a shared cancel flag.
10
11use super::button::Button;
12use super::filler::Filler;
13use super::label::Label;
14use super::{Align, Bus, Event, Hub, Id, RenderQueue, View, ViewId, ID_FEEDER};
15use crate::color::WHITE;
16use crate::context::Context;
17use crate::device::CURRENT_DEVICE;
18use crate::font::{font_from_style, Fonts, NORMAL_STYLE};
19use crate::framebuffer::Framebuffer;
20use crate::geom::Rectangle;
21use crate::gesture::GestureEvent;
22use crate::github::{GithubClient, GithubError, TokenPollResult};
23use crate::unit::scale_by_dpi;
24use crate::view::github::GithubEvent;
25use crate::view::ota::OtaViewId;
26use std::sync::atomic::{AtomicBool, Ordering};
27use std::sync::Arc;
28use std::thread;
29use std::time::Duration;
30
31/// Displays the GitHub device auth flow user code and polls for authorization.
32///
33/// Shows two lines of text:
34/// - The verification URL (`github.com/login/device`)
35/// - The user code to enter (e.g. `WDJB-MJHT`)
36///
37/// A Cancel button stops the background polling thread and closes the view.
38/// A background thread polls GitHub at the required interval. When the user
39/// authorizes, [`Event::Github`] with [`GithubEvent::DeviceAuthComplete`] is sent through the hub.
40pub struct DeviceAuthView {
41    id: Id,
42    rect: Rectangle,
43    children: Vec<Box<dyn View>>,
44    view_id: ViewId,
45    /// Shared flag — set to `true` to stop the polling thread.
46    cancelled: Arc<AtomicBool>,
47}
48
49impl DeviceAuthView {
50    /// Creates a new device auth view and immediately starts polling.
51    ///
52    /// Initiates the GitHub device auth flow, builds the UI with the user code,
53    /// and spawns a background thread to poll for authorization.
54    ///
55    /// # Arguments
56    ///
57    /// * `hub` - Event hub used to send auth result events
58    /// * `context` - Application context for font metrics
59    ///
60    /// # Errors
61    ///
62    /// If the device flow initiation fails, sends [`Event::Github`] with [`GithubEvent::DeviceAuthError`]
63    /// immediately and returns a view with an error message.
64    #[cfg_attr(feature = "otel", tracing::instrument(skip_all))]
65    pub fn new(hub: &Hub, context: &mut Context) -> Self {
66        let id = ID_FEEDER.next();
67        let view_id = ViewId::Ota(OtaViewId::DeviceAuth);
68        let (width, height) = CURRENT_DEVICE.dims;
69        let full_rect = rect![0, 0, width as i32, height as i32];
70        let cancelled = Arc::new(AtomicBool::new(false));
71
72        let mut children: Vec<Box<dyn View>> = Vec::new();
73        children.push(Box::new(Filler::new(full_rect, WHITE)));
74
75        let (url_text, code_text) = match Self::initiate_and_spawn(hub, Arc::clone(&cancelled)) {
76            Ok((url, code)) => (format!("Go to: {}", url), format!("Enter code: {}", code)),
77            Err(e) => {
78                tracing::error!(error = %e, "Device flow initiation failed");
79                hub.send(Event::Github(GithubEvent::DeviceAuthError(e.to_string())))
80                    .ok();
81                (
82                    "GitHub auth failed".to_owned(),
83                    "Check logs for details".to_owned(),
84                )
85            }
86        };
87
88        let dpi = CURRENT_DEVICE.dpi;
89        let font = font_from_style(&mut context.fonts, &NORMAL_STYLE, dpi);
90        let x_height = font.x_heights.0 as i32;
91        let padding = font.em() as i32;
92
93        let center_y = height as i32 / 2;
94        let line_height = 3 * x_height;
95
96        let url_rect = rect![
97            padding,
98            center_y - line_height - padding / 2,
99            width as i32 - padding,
100            center_y - padding / 2
101        ];
102        children.push(Box::new(Label::new(url_rect, url_text, Align::Center)));
103
104        let code_rect = rect![
105            padding,
106            center_y + padding / 2,
107            width as i32 - padding,
108            center_y + line_height + padding / 2
109        ];
110        children.push(Box::new(Label::new(code_rect, code_text, Align::Center)));
111
112        let button_width = scale_by_dpi(200.0, dpi) as i32;
113        let button_height = scale_by_dpi(40.0, dpi) as i32;
114        let button_x = (width as i32 - button_width) / 2;
115        let button_y = center_y + line_height + 2 * padding;
116        let cancel_rect = rect![
117            button_x,
118            button_y,
119            button_x + button_width,
120            button_y + button_height
121        ];
122        children.push(Box::new(Button::new(
123            cancel_rect,
124            Event::Close(view_id),
125            "Cancel".to_owned(),
126        )));
127
128        Self {
129            id,
130            rect: full_rect,
131            children,
132            view_id,
133            cancelled,
134        }
135    }
136
137    /// Initiates the device flow and spawns the polling thread.
138    ///
139    /// Returns `(verification_uri, user_code)` on success so the caller can
140    /// display them. The polling thread checks `cancelled` before each poll
141    /// and exits cleanly when it is set.
142    fn initiate_and_spawn(
143        hub: &Hub,
144        cancelled: Arc<AtomicBool>,
145    ) -> Result<(String, String), crate::github::GithubError> {
146        let client = GithubClient::new(None)?;
147        let device_code_response = client.initiate_device_flow().map_err(GithubError::Api)?;
148
149        let verification_uri = device_code_response.verification_uri.clone();
150        let user_code = device_code_response.user_code.clone();
151        let device_code = device_code_response.device_code.clone();
152        let interval_secs = device_code_response.interval;
153
154        tracing::info!(
155            user_code = %user_code,
156            verification_uri = %verification_uri,
157            "Device flow initiated"
158        );
159
160        let hub2 = hub.clone();
161        let parent_span = tracing::Span::current();
162
163        thread::spawn(move || {
164            let _span = tracing::info_span!(parent: &parent_span, "device_flow_poll").entered();
165
166            let poll_client = match GithubClient::new(None) {
167                Ok(c) => c,
168                Err(e) => {
169                    tracing::error!(error = %e, "Failed to create poll client");
170                    hub2.send(Event::Github(GithubEvent::DeviceAuthError(e.to_string())))
171                        .ok();
172                    return;
173                }
174            };
175
176            let mut interval = Duration::from_secs(interval_secs);
177
178            loop {
179                thread::sleep(interval);
180
181                if cancelled.load(Ordering::Relaxed) {
182                    tracing::info!("Device flow polling cancelled");
183                    return;
184                }
185
186                match poll_client.poll_device_token(&device_code) {
187                    Ok(TokenPollResult::Pending) => {
188                        tracing::debug!("Authorization pending, continuing to poll");
189                    }
190                    Ok(TokenPollResult::SlowDown) => {
191                        interval += Duration::from_secs(5);
192                        tracing::debug!(interval_secs = interval.as_secs(), "Slowing down poll");
193                    }
194                    Ok(TokenPollResult::Complete(token)) => {
195                        tracing::info!("Device flow authorization complete");
196                        hub2.send(Event::Github(GithubEvent::DeviceAuthComplete(token)))
197                            .ok();
198                        return;
199                    }
200                    Ok(TokenPollResult::Expired) => {
201                        tracing::warn!("Device flow code expired");
202                        hub2.send(Event::Github(GithubEvent::DeviceAuthExpired))
203                            .ok();
204                        return;
205                    }
206                    Ok(TokenPollResult::Cancelled) => {
207                        tracing::info!("Device flow cancelled by user on GitHub");
208                        hub2.send(Event::Github(GithubEvent::DeviceAuthError(
209                            "Authorization cancelled".to_owned(),
210                        )))
211                        .ok();
212                        return;
213                    }
214                    Err(e) => {
215                        tracing::error!(error = %e, "Device flow poll error");
216                        hub2.send(Event::Github(GithubEvent::DeviceAuthError(e)))
217                            .ok();
218                        return;
219                    }
220                }
221            }
222        });
223
224        Ok((verification_uri, user_code))
225    }
226
227    /// Stops the background polling thread.
228    fn cancel_polling(&self) {
229        self.cancelled.store(true, Ordering::Relaxed);
230    }
231}
232
233impl View for DeviceAuthView {
234    /// Handles events for the device auth view.
235    ///
236    /// Captures all tap gestures within the view to prevent parent views from
237    /// handling them (which would close the modal). The user must use the
238    /// Cancel button to close this view and return to the parent.
239    #[cfg_attr(
240        feature = "otel",
241        tracing::instrument(
242            skip(self, _hub, bus, _rq, _context),
243            fields(event = ?evt),
244            ret(level = tracing::Level::TRACE)
245        )
246    )]
247    fn handle_event(
248        &mut self,
249        evt: &Event,
250        _hub: &Hub,
251        bus: &mut Bus,
252        _rq: &mut RenderQueue,
253        _context: &mut Context,
254    ) -> bool {
255        match evt {
256            Event::Close(id) if *id == self.view_id => {
257                self.cancel_polling();
258                bus.push_back(Event::Close(ViewId::Ota(OtaViewId::Main)));
259                true
260            }
261            Event::Gesture(GestureEvent::Tap(center)) if self.rect.includes(*center) => true,
262            _ => false,
263        }
264    }
265
266    #[cfg_attr(
267        feature = "otel",
268        tracing::instrument(skip(self, _fb, _fonts, _rect), fields(rect = ?_rect))
269    )]
270    fn render(&self, _fb: &mut dyn Framebuffer, _rect: Rectangle, _fonts: &mut Fonts) {}
271
272    fn rect(&self) -> &Rectangle {
273        &self.rect
274    }
275
276    fn rect_mut(&mut self) -> &mut Rectangle {
277        &mut self.rect
278    }
279
280    fn children(&self) -> &Vec<Box<dyn View>> {
281        &self.children
282    }
283
284    fn children_mut(&mut self) -> &mut Vec<Box<dyn View>> {
285        &mut self.children
286    }
287
288    fn id(&self) -> Id {
289        self.id
290    }
291
292    fn view_id(&self) -> Option<ViewId> {
293        Some(self.view_id)
294    }
295
296    fn resize(
297        &mut self,
298        _rect: Rectangle,
299        _hub: &Hub,
300        _rq: &mut RenderQueue,
301        _context: &mut Context,
302    ) {
303    }
304}