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}