Skip to main content

wg_tui/app/
input.rs

1use std::{fs, path::Path, time::Duration};
2
3use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
4use qrcode::QrCode;
5
6use crate::{
7    error::Error,
8    types::Message,
9    wireguard::{
10        ImportConflictPolicy, add_server_peer, default_egress_interface, delete_tunnel,
11        detect_public_ip, expand_path, export_tunnels_to_zip, generate_private_key, import_tunnel,
12        import_tunnels, import_zip_conflict_count, is_full_tunnel_config, suggest_server_address,
13        wg_quick,
14    },
15};
16
17use super::{
18    App,
19    wizard::{NewTunnelWizard, PeerConfigState, PendingImport, PendingPeerConfig},
20};
21
22// ---------------------------------------------------------------------------
23// Text input helper
24// ---------------------------------------------------------------------------
25
26enum InputAction {
27    Submit,
28    Cancel,
29    Edited,
30    Unhandled,
31}
32
33fn handle_text_input(key: &crossterm::event::KeyEvent, buffer: &mut String) -> InputAction {
34    match key.code {
35        KeyCode::Enter => InputAction::Submit,
36        KeyCode::Esc => InputAction::Cancel,
37        KeyCode::Backspace => {
38            buffer.pop();
39            InputAction::Edited
40        }
41        KeyCode::Char(c) => {
42            buffer.push(c);
43            InputAction::Edited
44        }
45        _ => InputAction::Unhandled,
46    }
47}
48
49// ---------------------------------------------------------------------------
50// Event handling
51// ---------------------------------------------------------------------------
52
53impl App {
54    /// Polls for input events and dispatches them to the app.
55    ///
56    /// # Errors
57    ///
58    /// Returns an error if event polling or reading fails.
59    pub fn handle_events(&mut self) -> Result<(), Error> {
60        if !event::poll(Duration::from_millis(100))? {
61            return Ok(());
62        }
63
64        let Event::Key(key) = event::read()? else {
65            return Ok(());
66        };
67        if key.kind != KeyEventKind::Press {
68            return Ok(());
69        }
70
71        self.handle_key(key);
72        Ok(())
73    }
74
75    fn handle_key(&mut self, key: crossterm::event::KeyEvent) {
76        self.message = None;
77
78        if self.consume_help() {
79            return;
80        }
81        if self.consume_confirm_delete(key) {
82            return;
83        }
84        if self.consume_import_conflict_choice(key) {
85            return;
86        }
87        if self.consume_confirm_full_tunnel(key) {
88            return;
89        }
90        if self.consume_peer_save_path(key) {
91            return;
92        }
93        if self.consume_import_path(key) {
94            return;
95        }
96        if self.consume_import_zip(key) {
97            return;
98        }
99        if self.consume_export_path(key) {
100            return;
101        }
102        if self.consume_peer_endpoint_input(key) {
103            return;
104        }
105        if self.consume_peer_dns_input(key) {
106            return;
107        }
108        if self.consume_new_tunnel_wizard(key) {
109            return;
110        }
111        if self.consume_peer_config(key) {
112            return;
113        }
114        if self.consume_add_menu(key) {
115            return;
116        }
117
118        self.handle_global_key(key);
119    }
120
121    fn consume_help(&mut self) -> bool {
122        if self.flags.show_help() {
123            self.flags.set_show_help(false);
124            return true;
125        }
126        false
127    }
128
129    fn consume_confirm_delete(&mut self, key: crossterm::event::KeyEvent) -> bool {
130        if !self.flags.confirm_delete() {
131            return false;
132        }
133        if let KeyCode::Char('y' | 'Y') = key.code {
134            self.flags.set_confirm_delete(false);
135            self.delete_selected();
136        } else {
137            self.flags.set_confirm_delete(false);
138            self.message = Some(Message::Info("Delete cancelled".into()));
139        }
140        true
141    }
142
143    fn consume_confirm_full_tunnel(&mut self, key: crossterm::event::KeyEvent) -> bool {
144        let Some(ref name) = self.confirm_full_tunnel else {
145            return false;
146        };
147        if let KeyCode::Char('y' | 'Y') = key.code {
148            let name = name.clone();
149            self.confirm_full_tunnel = None;
150            self.toggle_selected_with_name(&name);
151        } else {
152            self.confirm_full_tunnel = None;
153            self.message = Some(Message::Info("Enable cancelled".into()));
154        }
155        true
156    }
157
158    fn consume_peer_save_path(&mut self, key: crossterm::event::KeyEvent) -> bool {
159        let Some(ref mut path) = self.peer_save_path else {
160            return false;
161        };
162        match handle_text_input(&key, path) {
163            InputAction::Submit => {
164                let dest = expand_path(path);
165                self.peer_save_path = None;
166                let Some(peer) = &self.peer_config else {
167                    return true;
168                };
169                if dest.exists() {
170                    self.message = Some(Message::Error("File already exists".into()));
171                    return true;
172                }
173                match fs::write(&dest, &peer.config_text) {
174                    Ok(()) => {
175                        self.message = Some(Message::Success(format!(
176                            "Peer config saved to {}",
177                            dest.display()
178                        )));
179                    }
180                    Err(e) => self.message = Some(Message::Error(e.to_string())),
181                }
182            }
183            InputAction::Cancel => {
184                self.peer_save_path = None;
185                self.message = Some(Message::Info("Save cancelled".into()));
186            }
187            InputAction::Edited | InputAction::Unhandled => {}
188        }
189        true
190    }
191
192    fn consume_import_path(&mut self, key: crossterm::event::KeyEvent) -> bool {
193        let Some(ref mut path) = self.input_path else {
194            return false;
195        };
196        match handle_text_input(&key, path) {
197            InputAction::Submit => {
198                let resolved = expand_path(path);
199                self.input_path = None;
200                match import_tunnel(&resolved) {
201                    Ok(name) => {
202                        self.message = Some(Message::Success(format!("Tunnel '{name}' imported")));
203                        self.refresh_tunnels();
204                    }
205                    Err(e) => self.message = Some(Message::Error(e.to_string())),
206                }
207            }
208            InputAction::Cancel => {
209                self.input_path = None;
210                self.message = Some(Message::Info("Import cancelled".into()));
211            }
212            InputAction::Edited | InputAction::Unhandled => {}
213        }
214        true
215    }
216
217    fn consume_import_zip(&mut self, key: crossterm::event::KeyEvent) -> bool {
218        let Some(ref mut path) = self.input_zip else {
219            return false;
220        };
221        match handle_text_input(&key, path) {
222            InputAction::Submit => {
223                let resolved = expand_path(path);
224                self.input_zip = None;
225                match import_zip_conflict_count(&resolved) {
226                    Ok(conflicts) if conflicts > 0 => {
227                        self.pending_import = Some(PendingImport {
228                            path: resolved,
229                            conflicts,
230                        });
231                    }
232                    Ok(_) => self.finish_import(&resolved, ImportConflictPolicy::SkipConflicts),
233                    Err(e) => self.message = Some(Message::Error(e.to_string())),
234                }
235            }
236            InputAction::Cancel => {
237                self.input_zip = None;
238                self.message = Some(Message::Info("Import cancelled".into()));
239            }
240            InputAction::Edited | InputAction::Unhandled => {}
241        }
242        true
243    }
244
245    fn consume_import_conflict_choice(&mut self, key: crossterm::event::KeyEvent) -> bool {
246        let Some(pending) = self.pending_import.take() else {
247            return false;
248        };
249
250        match key.code {
251            KeyCode::Char('y' | 'Y') => {
252                self.finish_import(&pending.path, ImportConflictPolicy::AutoRename);
253            }
254            KeyCode::Char('n' | 'N') => {
255                self.finish_import(&pending.path, ImportConflictPolicy::SkipConflicts);
256            }
257            _ => {
258                self.message = Some(Message::Info("Import cancelled".into()));
259            }
260        }
261
262        true
263    }
264
265    fn consume_export_path(&mut self, key: crossterm::event::KeyEvent) -> bool {
266        let Some(ref mut path) = self.export_path else {
267            return false;
268        };
269        match handle_text_input(&key, path) {
270            InputAction::Submit => {
271                let dest = expand_path(path);
272                self.export_path = None;
273                match export_tunnels_to_zip(&dest) {
274                    Ok(()) => {
275                        self.message = Some(Message::Success(format!(
276                            "Exported {} tunnels to {}",
277                            self.tunnels.len(),
278                            dest.display()
279                        )));
280                    }
281                    Err(e) => self.message = Some(Message::Error(e.to_string())),
282                }
283            }
284            InputAction::Cancel => {
285                self.export_path = None;
286                self.message = Some(Message::Info("Export cancelled".into()));
287            }
288            InputAction::Edited | InputAction::Unhandled => {}
289        }
290        true
291    }
292
293    fn consume_peer_endpoint_input(&mut self, key: crossterm::event::KeyEvent) -> bool {
294        let Some(ref mut endpoint) = self.peer_endpoint_input else {
295            return false;
296        };
297        match handle_text_input(&key, endpoint) {
298            InputAction::Submit => {
299                let endpoint_str = endpoint.trim().to_string();
300                if endpoint_str.is_empty() {
301                    self.message = Some(Message::Error("Endpoint is required".into()));
302                    return true;
303                }
304                if let Some(pending) = self.pending_peer.as_mut() {
305                    pending.endpoint = endpoint_str;
306                }
307                self.peer_endpoint_input = None;
308                self.peer_dns_input = Some(String::new());
309            }
310            InputAction::Cancel => {
311                self.peer_endpoint_input = None;
312                self.pending_peer = None;
313                self.message = Some(Message::Info("Peer config cancelled".into()));
314            }
315            InputAction::Edited | InputAction::Unhandled => {}
316        }
317        true
318    }
319
320    fn consume_peer_dns_input(&mut self, key: crossterm::event::KeyEvent) -> bool {
321        let Some(ref mut dns) = self.peer_dns_input else {
322            return false;
323        };
324        match handle_text_input(&key, dns) {
325            InputAction::Submit => {
326                let dns_str = dns.trim().to_string();
327                let Some(pending) = self.pending_peer.take() else {
328                    self.peer_dns_input = None;
329                    return true;
330                };
331                let dns_block = if dns_str.is_empty() {
332                    String::new()
333                } else {
334                    format!("DNS = {dns_str}\n")
335                };
336                let config_text = pending
337                    .template
338                    .replace("__ENDPOINT__", &pending.endpoint)
339                    .replace("__DNS_BLOCK__", &dns_block);
340                self.peer_config = Some(PeerConfigState::new(config_text, pending.suggested_path));
341                self.peer_dns_input = None;
342            }
343            InputAction::Cancel => {
344                self.peer_dns_input = None;
345                self.pending_peer = None;
346                self.message = Some(Message::Info("Peer config cancelled".into()));
347            }
348            InputAction::Edited | InputAction::Unhandled => {}
349        }
350        true
351    }
352
353    fn consume_new_tunnel_wizard(&mut self, key: crossterm::event::KeyEvent) -> bool {
354        let Some(ref mut wizard) = self.new_tunnel else {
355            return false;
356        };
357        match handle_text_input(&key, wizard.current_value_mut()) {
358            InputAction::Submit => {
359                if let Some(err) = wizard.validate_current() {
360                    self.message = Some(Message::Error(err));
361                    return true;
362                }
363                let finished = wizard.advance();
364                if finished {
365                    let wizard = self.new_tunnel.take().unwrap();
366                    match wizard.create() {
367                        Ok(name) => {
368                            self.message =
369                                Some(Message::Success(format!("Tunnel '{name}' created")));
370                            self.refresh_tunnels();
371                        }
372                        Err(e) => self.message = Some(Message::Error(e.to_string())),
373                    }
374                }
375            }
376            InputAction::Cancel => {
377                self.new_tunnel = None;
378                self.message = Some(Message::Info("Create cancelled".into()));
379            }
380            InputAction::Edited | InputAction::Unhandled => {}
381        }
382        true
383    }
384
385    fn consume_peer_config(&mut self, key: crossterm::event::KeyEvent) -> bool {
386        let Some(ref mut peer) = self.peer_config else {
387            return false;
388        };
389        match key.code {
390            KeyCode::Char('s') => {
391                self.peer_save_path = Some(peer.suggested_path.clone());
392                peer.show_qr = false;
393            }
394            KeyCode::Char('q') => {
395                if let Ok(code) = QrCode::new(peer.config_text.as_bytes()) {
396                    peer.qr_code = Some(code);
397                    peer.show_qr = true;
398                } else {
399                    peer.show_qr = false;
400                    self.message = Some(Message::Error("QR data is too large".into()));
401                }
402            }
403            KeyCode::Char('b') => {
404                peer.show_qr = false;
405            }
406            KeyCode::Esc => {
407                self.peer_config = None;
408            }
409            _ => {}
410        }
411        true
412    }
413
414    fn consume_add_menu(&mut self, key: crossterm::event::KeyEvent) -> bool {
415        if !self.flags.show_add_menu() {
416            return false;
417        }
418        match key.code {
419            KeyCode::Char('i' | '1') => {
420                self.flags.set_show_add_menu(false);
421                self.input_path = Some(String::new());
422            }
423            KeyCode::Char('z' | '2') => {
424                self.flags.set_show_add_menu(false);
425                self.input_zip = Some(String::new());
426            }
427            KeyCode::Char('c' | '3') => {
428                self.flags.set_show_add_menu(false);
429                let name = self.default_tunnel_name();
430                self.new_tunnel = Some(NewTunnelWizard::client(name));
431            }
432            KeyCode::Char('s' | '4') => {
433                self.flags.set_show_add_menu(false);
434                let name = self.default_tunnel_name();
435                let address = suggest_server_address();
436                let egress = default_egress_interface().unwrap_or_default();
437                let private_key = match generate_private_key() {
438                    Ok(key) => key,
439                    Err(e) => {
440                        self.message = Some(Message::Error(e.to_string()));
441                        return true;
442                    }
443                };
444                self.new_tunnel = Some(NewTunnelWizard::server(
445                    name,
446                    address,
447                    "51820".into(),
448                    private_key,
449                    egress,
450                ));
451            }
452            KeyCode::Esc | KeyCode::Char('q') => {
453                self.flags.set_show_add_menu(false);
454            }
455            _ => {}
456        }
457        true
458    }
459
460    fn handle_global_key(&mut self, key: crossterm::event::KeyEvent) {
461        match (key.code, key.modifiers) {
462            (KeyCode::Char('q') | KeyCode::Esc, _) => self.flags.set_should_quit(true),
463            (KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) => {
464                self.flags.set_should_quit(true);
465            }
466            (KeyCode::Char('j') | KeyCode::Down, _) => self.move_selection(1),
467            (KeyCode::Char('k') | KeyCode::Up, _) => self.move_selection(-1),
468            (KeyCode::Char('g'), _) => self.list_state.select(Some(0)),
469            (KeyCode::Char('G'), _) => self
470                .list_state
471                .select(Some(self.tunnels.len().saturating_sub(1))),
472            (KeyCode::Enter | KeyCode::Char(' '), _) => self.toggle_selected(),
473            (KeyCode::Char('d'), _) => self.flags.toggle_show_details(),
474            (KeyCode::Char('x'), _) => {
475                if self.selected().is_some() {
476                    self.flags.set_confirm_delete(true);
477                }
478            }
479            (KeyCode::Char('a'), _) => self.flags.set_show_add_menu(true),
480            (KeyCode::Char('p'), _) => {
481                let Some(tunnel) = self.selected() else {
482                    return;
483                };
484                match add_server_peer(&tunnel.name) {
485                    Ok(peer) => {
486                        let endpoint = detect_public_ip()
487                            .map(|ip| format!("{ip}:{}", peer.listen_port))
488                            .unwrap_or_default();
489                        self.pending_peer = Some(PendingPeerConfig::new(
490                            peer.client_config_template,
491                            peer.suggested_filename,
492                            endpoint.clone(),
493                        ));
494                        self.peer_endpoint_input = Some(endpoint);
495                        self.message = Some(Message::Success("Peer added".into()));
496                        self.refresh_tunnels();
497                    }
498                    Err(e) => self.message = Some(Message::Error(e.to_string())),
499                }
500            }
501            (KeyCode::Char('e'), _) => {
502                if self.tunnels.is_empty() {
503                    self.message = Some(Message::Error("No tunnels to export".into()));
504                } else {
505                    self.export_path = Some("wg-tunnels.zip".into());
506                }
507            }
508            (KeyCode::Char('r'), _) => {
509                self.refresh_tunnels();
510                self.message = Some(Message::Info("Refreshed".into()));
511            }
512            (KeyCode::Char('?'), _) => self.flags.set_show_help(true),
513            _ => {}
514        }
515    }
516
517    pub(super) fn toggle_selected(&mut self) {
518        let Some(tunnel) = self.selected() else {
519            return;
520        };
521        let (name, active) = (tunnel.name.clone(), tunnel.is_active);
522
523        if !active && is_full_tunnel_config(&name) {
524            self.confirm_full_tunnel = Some(name);
525            return;
526        }
527
528        self.toggle_selected_with_name(&name);
529    }
530
531    pub(super) fn toggle_selected_with_name(&mut self, name: &str) {
532        let active = self
533            .tunnels
534            .iter()
535            .find(|t| t.name == name)
536            .is_some_and(|t| t.is_active);
537
538        match wg_quick(if active { "down" } else { "up" }, name) {
539            Ok(()) => {
540                self.message = Some(Message::Success(format!(
541                    "Tunnel '{name}' {}",
542                    if active { "stopped" } else { "started" }
543                )));
544                self.refresh_tunnels();
545            }
546            Err(e) => self.message = Some(Message::Error(e.to_string())),
547        }
548    }
549
550    pub(super) fn delete_selected(&mut self) {
551        let Some(tunnel) = self.selected() else {
552            return;
553        };
554        let (name, active) = (tunnel.name.clone(), tunnel.is_active);
555
556        match delete_tunnel(&name, active) {
557            Ok(()) => {
558                self.message = Some(Message::Success(format!("Tunnel '{name}' deleted")));
559                self.refresh_tunnels();
560            }
561            Err(e) => self.message = Some(Message::Error(e.to_string())),
562        }
563    }
564
565    fn finish_import(&mut self, path: &Path, policy: ImportConflictPolicy) {
566        match import_tunnels(path, policy) {
567            Ok(count) => {
568                self.message = Some(Message::Success(format!("{count} Tunnel(s) imported")));
569                self.refresh_tunnels();
570            }
571            Err(e) => self.message = Some(Message::Error(e.to_string())),
572        }
573    }
574}