Skip to main content

nm_wifi/
app.rs

1use std::{
2    error::Error,
3    time::{Duration, Instant},
4};
5
6use crossterm::event::{self, Event, KeyCode, KeyEventKind};
7use ratatui::{Terminal, backend::Backend};
8
9use crate::{
10    app_state::{App, AppState, OperationKind},
11    backend::{NetworkBackend, default_runtime_driver},
12    network::ConnectionRequest,
13    ui::ui,
14    wifi::WifiNetwork,
15};
16
17#[cfg_attr(not(test), allow(dead_code))]
18pub(crate) mod runtime;
19
20pub struct CleanupGuard<F: FnOnce()> {
21    cleanup: Option<F>,
22}
23
24impl<F: FnOnce()> CleanupGuard<F> {
25    pub fn new(cleanup: F) -> Self {
26        Self {
27            cleanup: Some(cleanup),
28        }
29    }
30
31    pub fn dismiss(mut self) {
32        self.cleanup = None;
33    }
34}
35
36impl<F: FnOnce()> Drop for CleanupGuard<F> {
37    fn drop(&mut self) {
38        if let Some(cleanup) = self.cleanup.take() {
39            cleanup();
40        }
41    }
42}
43
44pub fn begin_disconnect_for_selected_network(app: &mut App) {
45    if let Some(network) = app
46        .selected_network_in_list()
47        .filter(|n| n.connected)
48        .cloned()
49    {
50        app.begin_operation(network, OperationKind::Disconnect);
51    }
52}
53
54const CONNECTION_COMPLETION_REQUIRES_NETWORK: &str =
55    "connection completion requires a selected network";
56const DISCONNECTION_COMPLETION_REQUIRES_NETWORK: &str =
57    "disconnection completion requires a selected network";
58
59fn selected_network_for_operation<'a>(
60    app: &'a App,
61    message: &'static str,
62) -> &'a WifiNetwork {
63    app.selected_network.as_ref().expect(message)
64}
65
66fn apply_scanned_networks(
67    app: &mut App,
68    networks: Vec<WifiNetwork>,
69    adapter_name: Option<String>,
70) {
71    let previous_count = app.networks.len();
72    app.networks = networks;
73    app.network_count = app.networks.len();
74    app.last_scan_time = Some(Instant::now());
75
76    if app.adapter_name.is_none() {
77        app.adapter_name = adapter_name;
78    }
79
80    if previous_count == 0 && !app.networks.is_empty() {
81        if app.selected_network.is_some() {
82            app.update_selection_after_rescan();
83        } else {
84            app.selected_index = 0;
85        }
86    }
87
88    if !app.networks.is_empty() {
89        app.status_message = format!(
90            "Found {} network(s). Ready to connect!",
91            app.networks.len()
92        );
93        app.state = AppState::NetworkList;
94    } else {
95        app.status_message = "Scanning for WiFi networks...".to_string();
96    }
97}
98
99async fn refresh_networks(backend: &dyn NetworkBackend, app: &mut App) {
100    let networks = match backend.scan_networks().await {
101        Ok(networks) => networks,
102        Err(error) => {
103            app.handle_scan_error(error);
104            return;
105        }
106    };
107    let adapter_name = if app.adapter_name.is_none() {
108        backend.adapter_name().ok().flatten()
109    } else {
110        None
111    };
112
113    apply_scanned_networks(app, networks, adapter_name);
114}
115
116pub async fn refresh_networks_with_backend(
117    backend: &dyn NetworkBackend,
118    app: &mut App,
119) -> Result<(), Box<dyn Error>> {
120    refresh_networks(backend, app).await;
121    Ok(())
122}
123
124fn complete_connection(backend: &dyn NetworkBackend, app: &mut App) {
125    let network = selected_network_for_operation(
126        app,
127        CONNECTION_COMPLETION_REQUIRES_NETWORK,
128    );
129    let request = if network.security.is_secured() {
130        ConnectionRequest::Secured {
131            network,
132            passphrase: app.password_input.as_str(),
133        }
134    } else {
135        ConnectionRequest::Open { network }
136    };
137
138    match backend.connect(request) {
139        Ok(_) => app.finish_operation(true, None),
140        Err(error) => app.finish_operation(false, Some(error.to_string())),
141    }
142}
143
144pub fn complete_connection_with_backend(
145    backend: &dyn NetworkBackend,
146    app: &mut App,
147) -> Result<(), Box<dyn Error>> {
148    complete_connection(backend, app);
149    Ok(())
150}
151
152fn complete_disconnection(backend: &dyn NetworkBackend, app: &mut App) {
153    let network = selected_network_for_operation(
154        app,
155        DISCONNECTION_COMPLETION_REQUIRES_NETWORK,
156    );
157
158    match backend.disconnect(network) {
159        Ok(_) => app.finish_operation(true, None),
160        Err(error) => app.finish_operation(false, Some(error.to_string())),
161    }
162}
163
164pub fn complete_disconnection_with_backend(
165    backend: &dyn NetworkBackend,
166    app: &mut App,
167) -> Result<(), Box<dyn Error>> {
168    complete_disconnection(backend, app);
169    Ok(())
170}
171
172fn handle_scanning_keypress(app: &mut App, key: KeyCode) {
173    match key {
174        KeyCode::Esc => app.quit(),
175        KeyCode::Char('j') | KeyCode::Down if !app.networks.is_empty() => {
176            app.next()
177        }
178        KeyCode::Char('k') | KeyCode::Up if !app.networks.is_empty() => {
179            app.previous()
180        }
181        KeyCode::Enter | KeyCode::Char('c') if !app.networks.is_empty() => {
182            app.activate_selected_network()
183        }
184        _ => {}
185    }
186}
187
188async fn handle_scanning_state(
189    backend: &dyn NetworkBackend,
190    app: &mut App,
191) -> Result<(), Box<dyn Error>> {
192    if event::poll(Duration::from_millis(100))? {
193        if let Event::Key(key) = event::read()?
194            && key.kind == KeyEventKind::Press
195        {
196            handle_scanning_keypress(app, key.code);
197        }
198        return Ok(());
199    }
200
201    refresh_networks(backend, app).await;
202    Ok(())
203}
204
205async fn handle_connection_state(
206    backend: &dyn NetworkBackend,
207    app: &mut App,
208) -> Result<(), Box<dyn Error>> {
209    if event::poll(Duration::from_millis(100))?
210        && let Event::Key(key) = event::read()?
211        && key.kind == KeyEventKind::Press
212        && key.code == KeyCode::Esc
213    {
214        app.quit();
215        return Ok(());
216    }
217
218    complete_connection(backend, app);
219    Ok(())
220}
221
222async fn handle_disconnection_state(
223    backend: &dyn NetworkBackend,
224    app: &mut App,
225) -> Result<(), Box<dyn Error>> {
226    if event::poll(Duration::from_millis(100))?
227        && let Event::Key(key) = event::read()?
228        && key.kind == KeyEventKind::Press
229        && key.code == KeyCode::Esc
230    {
231        app.quit();
232        return Ok(());
233    }
234
235    complete_disconnection(backend, app);
236    Ok(())
237}
238
239fn handle_keypress(app: &mut App, key: KeyCode) {
240    match app.state {
241        AppState::NetworkList => match key {
242            KeyCode::Char('q') | KeyCode::Esc => app.quit(),
243            KeyCode::Char('j') | KeyCode::Down => app.next(),
244            KeyCode::Char('k') | KeyCode::Up => app.previous(),
245            KeyCode::Enter | KeyCode::Char('c') => {
246                app.activate_selected_network()
247            }
248            KeyCode::Char('d') => begin_disconnect_for_selected_network(app),
249            KeyCode::Char('r') => app.start_scan(),
250            KeyCode::Char('h') => app.state = AppState::Help,
251            KeyCode::Char('i') if !app.networks.is_empty() => {
252                app.state = AppState::NetworkDetails;
253            }
254            _ => {}
255        },
256        AppState::Help => match key {
257            KeyCode::Esc | KeyCode::Char('h') | KeyCode::Char('q') => {
258                app.state = AppState::NetworkList;
259            }
260            _ => {}
261        },
262        AppState::NetworkDetails => match key {
263            KeyCode::Esc | KeyCode::Char('i') | KeyCode::Char('q') => {
264                app.state = AppState::NetworkList;
265            }
266            _ => {}
267        },
268        AppState::PasswordInput => match key {
269            KeyCode::Esc => {
270                app.state = AppState::NetworkList;
271                app.password_input.clear();
272                app.password_visible = false;
273            }
274            KeyCode::Enter => app.confirm_password(),
275            KeyCode::Backspace => app.remove_char_from_password(),
276            KeyCode::Tab => app.password_visible = !app.password_visible,
277            KeyCode::Char(c) => app.add_char_to_password(c),
278            _ => {}
279        },
280        AppState::ConnectionResult => match key {
281            KeyCode::Char('q') | KeyCode::Esc => app.quit(),
282            KeyCode::Enter => {
283                app.back_to_network_list();
284                app.start_scan();
285            }
286            _ => {}
287        },
288        AppState::Scanning | AppState::Connecting | AppState::Disconnecting => {
289        }
290    }
291}
292
293pub async fn run_app_with_backend<B>(
294    terminal: &mut Terminal<B>,
295    backend: &dyn NetworkBackend,
296    mut app: App,
297) -> Result<(), Box<dyn Error>>
298where
299    B: Backend,
300    B::Error: Error + 'static,
301{
302    loop {
303        terminal.draw(|frame| ui(frame, &app))?;
304
305        if app.should_quit {
306            break;
307        }
308
309        match app.state {
310            AppState::Scanning => {
311                handle_scanning_state(backend, &mut app).await?;
312                continue;
313            }
314            AppState::Connecting => {
315                handle_connection_state(backend, &mut app).await?;
316                continue;
317            }
318            AppState::Disconnecting => {
319                handle_disconnection_state(backend, &mut app).await?;
320                continue;
321            }
322            _ => {}
323        }
324
325        if event::poll(Duration::from_millis(100))?
326            && let Event::Key(key) = event::read()?
327            && key.kind == KeyEventKind::Press
328        {
329            handle_keypress(&mut app, key.code);
330        }
331    }
332
333    Ok(())
334}
335
336pub async fn run_app<B>(
337    terminal: &mut Terminal<B>,
338    app: App,
339) -> Result<(), Box<dyn Error>>
340where
341    B: Backend,
342    B::Error: Error + 'static,
343{
344    let mut input = runtime::CrosstermInput;
345    let mut runtime_driver = default_runtime_driver();
346    runtime::run_app_with_runtime(
347        terminal,
348        &mut input,
349        runtime_driver.as_mut(),
350        app,
351    )
352    .await
353    .map(|_| ())
354}
355
356#[cfg(test)]
357mod tests {
358    use std::{cell::RefCell, error::Error, rc::Rc};
359
360    use super::{
361        CleanupGuard,
362        begin_disconnect_for_selected_network,
363        complete_connection,
364        complete_disconnection,
365    };
366    use crate::{
367        app_state::{App, AppState},
368        backend::{BackendFuture, NetworkBackend},
369        network::ConnectionRequest,
370        wifi::{WifiNetwork, WifiSecurity},
371    };
372
373    struct NoopBackend;
374
375    impl NetworkBackend for NoopBackend {
376        fn connected_ssid(&self) -> Result<Option<String>, Box<dyn Error>> {
377            Ok(None)
378        }
379
380        fn adapter_name(&self) -> Result<Option<String>, Box<dyn Error>> {
381            Ok(None)
382        }
383
384        fn scan_networks(
385            &self,
386        ) -> BackendFuture<'_, Result<Vec<WifiNetwork>, Box<dyn Error>>>
387        {
388            Box::pin(async { Ok(Vec::new()) })
389        }
390
391        fn connect(
392            &self,
393            _request: ConnectionRequest<'_>,
394        ) -> Result<(), Box<dyn Error>> {
395            Ok(())
396        }
397
398        fn disconnect(
399            &self,
400            _network: &WifiNetwork,
401        ) -> Result<(), Box<dyn Error>> {
402            Ok(())
403        }
404    }
405
406    fn network(ssid: &str, connected: bool) -> WifiNetwork {
407        WifiNetwork {
408            ssid: ssid.to_string(),
409            signal_strength: 80,
410            security: WifiSecurity::WpaPsk,
411            frequency: 5180,
412            connected,
413        }
414    }
415
416    #[test]
417    fn cleanup_guard_runs_cleanup_on_drop() {
418        let cleaned = Rc::new(RefCell::new(false));
419        let cleaned_for_drop = Rc::clone(&cleaned);
420
421        {
422            let _guard = CleanupGuard::new(move || {
423                *cleaned_for_drop.borrow_mut() = true;
424            });
425        }
426
427        assert!(*cleaned.borrow());
428    }
429
430    #[test]
431    fn disconnect_shortcut_uses_current_selected_connected_network() {
432        let mut app = App::new();
433        app.state = AppState::NetworkList;
434        app.networks = vec![network("guest", false), network("home", true)];
435        app.selected_index = 1;
436
437        begin_disconnect_for_selected_network(&mut app);
438
439        assert!(matches!(app.state, AppState::Disconnecting));
440        assert!(app.is_disconnect_operation);
441        assert!(app.connection_start_time.is_some());
442        assert_eq!(
443            app.selected_network
444                .as_ref()
445                .map(|network| network.ssid.as_str()),
446            Some("home")
447        );
448        assert_eq!(app.status_message, "Disconnecting from home...");
449    }
450
451    #[test]
452    fn disconnect_shortcut_ignores_unconnected_selected_network() {
453        let mut app = App::new();
454        app.state = AppState::NetworkList;
455        app.networks = vec![network("guest", false), network("home", true)];
456        app.selected_index = 0;
457
458        begin_disconnect_for_selected_network(&mut app);
459
460        assert!(matches!(app.state, AppState::NetworkList));
461        assert!(!app.is_disconnect_operation);
462        assert!(app.connection_start_time.is_none());
463        assert!(app.selected_network.is_none());
464    }
465
466    #[test]
467    #[should_panic(
468        expected = "connection completion requires a selected network"
469    )]
470    fn connection_completion_requires_selected_network() {
471        let backend = NoopBackend;
472        let mut app = App::new();
473
474        complete_connection(&backend, &mut app);
475    }
476
477    #[test]
478    #[should_panic(
479        expected = "disconnection completion requires a selected network"
480    )]
481    fn disconnection_completion_requires_selected_network() {
482        let backend = NoopBackend;
483        let mut app = App::new();
484
485        complete_disconnection(&backend, &mut app);
486    }
487}