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
22enum 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
49impl App {
54 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}