1use crate::components::popup::upgrade_launchpad::UpgradeLaunchpadPopup;
10use crate::upnp::{UpnpSupport, get_upnp_support};
11use crate::{
12 action::Action,
13 components::{
14 Component,
15 help::Help,
16 options::Options,
17 popup::{
18 change_drive::ChangeDrivePopup, connection_mode::ChangeConnectionModePopUp,
19 manage_nodes::ManageNodes, port_range::PortRangePopUp, remove_node::RemoveNodePopUp,
20 reset_nodes::ResetNodesPopup, rewards_address::RewardsAddress,
21 upgrade_nodes::UpgradeNodesPopUp,
22 },
23 status::{Status, StatusConfig},
24 },
25 config::{AppData, Config, get_launchpad_nodes_data_dir_path},
26 connection_mode::ConnectionMode,
27 mode::{InputMode, Scene},
28 node_mgmt::{PORT_MAX, PORT_MIN},
29 style::SPACE_CADET,
30 system::{get_default_mount_point, get_primary_mount_point, get_primary_mount_point_name},
31 tui,
32};
33use ant_bootstrap::InitialPeersConfig;
34use color_eyre::eyre::Result;
35use crossterm::event::KeyEvent;
36use ratatui::{prelude::Rect, style::Style, widgets::Block};
37use std::path::PathBuf;
38use tokio::sync::mpsc;
39
40pub struct App {
41 pub config: Config,
42 pub app_data: AppData,
43 pub tick_rate: f64,
44 pub frame_rate: f64,
45 pub components: Vec<Box<dyn Component>>,
46 pub should_quit: bool,
47 pub should_suspend: bool,
48 pub input_mode: InputMode,
49 pub scene: Scene,
50 pub last_tick_key_events: Vec<KeyEvent>,
51}
52
53impl App {
54 pub async fn new(
55 tick_rate: f64,
56 frame_rate: f64,
57 init_peers_config: InitialPeersConfig,
58 antnode_path: Option<PathBuf>,
59 app_data_path: Option<PathBuf>,
60 network_id: Option<u8>,
61 ) -> Result<Self> {
62 let app_data = AppData::load(app_data_path)?;
64 let config = Config::new()?;
65
66 let data_dir_path = match &app_data.storage_mountpoint {
71 Some(path) => get_launchpad_nodes_data_dir_path(&PathBuf::from(path), true)?,
72 None => match get_default_mount_point() {
73 Ok((_, path)) => get_launchpad_nodes_data_dir_path(&path, true)?,
74 Err(_) => get_launchpad_nodes_data_dir_path(&get_primary_mount_point(), true)?,
75 },
76 };
77 debug!("Data dir path for nodes: {data_dir_path:?}");
78
79 let connection_mode = app_data
81 .connection_mode
82 .unwrap_or(ConnectionMode::Automatic);
83
84 let upnp_support = UpnpSupport::Loading;
85
86 let port_from = app_data.port_from.unwrap_or(PORT_MIN);
87 let port_to = app_data.port_to.unwrap_or(PORT_MAX);
88 let storage_mountpoint = app_data
89 .storage_mountpoint
90 .clone()
91 .unwrap_or(get_primary_mount_point());
92 let storage_drive = app_data
93 .storage_drive
94 .clone()
95 .unwrap_or(get_primary_mount_point_name()?);
96
97 let status_config = StatusConfig {
99 allocated_disk_space: app_data.nodes_to_start,
100 rewards_address: app_data.discord_username.clone(),
101 init_peers_config,
102 network_id,
103 antnode_path,
104 data_dir_path,
105 connection_mode,
106 upnp_support,
107 port_from: Some(port_from),
108 port_to: Some(port_to),
109 storage_mountpoint: storage_mountpoint.clone(),
110 };
111
112 let status = Status::new(status_config).await?;
113 let options = Options::new(
114 storage_mountpoint.clone(),
115 storage_drive.clone(),
116 app_data.discord_username.clone(),
117 connection_mode,
118 Some(port_from),
119 Some(port_to),
120 )
121 .await?;
122 let help = Help::new().await?;
123
124 let reset_nodes = ResetNodesPopup::default();
126 let manage_nodes = ManageNodes::new(app_data.nodes_to_start, storage_mountpoint.clone())?;
127 let change_drive =
128 ChangeDrivePopup::new(storage_mountpoint.clone(), app_data.nodes_to_start)?;
129 let change_connection_mode = ChangeConnectionModePopUp::new(connection_mode)?;
130 let port_range = PortRangePopUp::new(connection_mode, port_from, port_to);
131 let rewards_address = RewardsAddress::new(app_data.discord_username.clone());
132 let upgrade_nodes = UpgradeNodesPopUp::new();
133 let remove_node = RemoveNodePopUp::default();
134 let upgrade_launchpad_popup = UpgradeLaunchpadPopup::default();
135
136 let components: Vec<Box<dyn Component>> = vec![
137 Box::new(status),
139 Box::new(options),
140 Box::new(help),
141 Box::new(change_drive),
143 Box::new(change_connection_mode),
144 Box::new(port_range),
145 Box::new(rewards_address),
146 Box::new(reset_nodes),
147 Box::new(manage_nodes),
148 Box::new(upgrade_nodes),
149 Box::new(remove_node),
150 Box::new(upgrade_launchpad_popup),
151 ];
152
153 Ok(Self {
154 config,
155 app_data: AppData {
156 discord_username: app_data.discord_username.clone(),
157 nodes_to_start: app_data.nodes_to_start,
158 storage_mountpoint: Some(storage_mountpoint),
159 storage_drive: Some(storage_drive),
160 connection_mode: Some(connection_mode),
161 port_from: Some(port_from),
162 port_to: Some(port_to),
163 },
164 tick_rate,
165 frame_rate,
166 components,
167 should_quit: false,
168 should_suspend: false,
169 input_mode: InputMode::Navigation,
170 scene: Scene::Status,
171 last_tick_key_events: Vec::new(),
172 })
173 }
174
175 pub async fn run(&mut self) -> Result<()> {
176 let (action_tx, mut action_rx) = mpsc::unbounded_channel();
177
178 let action_tx_clone = action_tx.clone();
179
180 tokio::spawn(async move {
181 let upnp_support = get_upnp_support();
182 let _ = action_tx_clone.send(Action::SetUpnpSupport(upnp_support));
183 });
184
185 let mut tui = tui::Tui::new()?
186 .tick_rate(self.tick_rate)
187 .frame_rate(self.frame_rate);
188 tui.enter()?;
190
191 for component in self.components.iter_mut() {
192 component.register_action_handler(action_tx.clone())?;
193 component.register_config_handler(self.config.clone())?;
194 let size = tui.size()?;
195 let rect = Rect::new(0, 0, size.width, size.height);
196 component.init(rect)?;
197 }
198
199 loop {
200 if let Some(e) = tui.next().await {
201 match e {
202 tui::Event::Quit => action_tx.send(Action::Quit)?,
203 tui::Event::Tick => action_tx.send(Action::Tick)?,
204 tui::Event::Render => action_tx.send(Action::Render)?,
205 tui::Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
206 tui::Event::Key(key) => {
207 debug!("App received key event: {:?}", key);
208 if self.input_mode == InputMode::Navigation {
209 if let Some(keymap) = self.config.keybindings.get(&self.scene) {
210 if let Some(action) = keymap.get(&vec![key]) {
211 info!("Got action: {action:?}");
212 action_tx.send(action.clone())?;
213 } else {
214 debug!(
215 "Key {:?} not found in keymap for scene {:?}",
216 key, self.scene
217 );
218 self.last_tick_key_events.push(key);
221
222 if let Some(action) = keymap.get(&self.last_tick_key_events) {
224 info!("Got action: {action:?}");
225 action_tx.send(action.clone())?;
226 } else if self.last_tick_key_events.len() > 1 {
227 debug!(
228 "Multi-key combination {:?} not found in keymap",
229 self.last_tick_key_events
230 );
231 }
232 }
233 };
234 } else if self.input_mode == InputMode::Entry {
235 for component in self.components.iter_mut() {
236 let send_back_actions = component.handle_events(Some(e.clone()))?;
237 for action in send_back_actions {
238 action_tx.send(action)?;
239 }
240 }
241 }
242 }
243 _ => {}
244 }
245 }
246
247 while let Ok(action) = action_rx.try_recv() {
248 if action != Action::Tick && action != Action::Render {
249 debug!("{action:?}");
250 }
251 match action {
252 Action::Tick => {
253 self.last_tick_key_events.drain(..);
254 }
255 Action::Quit => self.should_quit = true,
256 Action::Suspend => self.should_suspend = true,
257 Action::Resume => self.should_suspend = false,
258 Action::Resize(w, h) => {
259 tui.resize(Rect::new(0, 0, w, h))?;
260 tui.draw(|f| {
261 for component in self.components.iter_mut() {
262 let r = component.draw(f, f.area());
263 if let Err(e) = r {
264 action_tx
265 .send(Action::Error(format!("Failed to draw: {e:?}")))
266 .unwrap();
267 }
268 }
269 })?;
270 }
271 Action::Render => {
272 tui.draw(|f| {
273 f.render_widget(
274 Block::new().style(Style::new().bg(SPACE_CADET)),
275 f.area(),
276 );
277 for component in self.components.iter_mut() {
278 let r = component.draw(f, f.area());
279 if let Err(e) = r {
280 action_tx
281 .send(Action::Error(format!("Failed to draw: {e:?}")))
282 .unwrap();
283 }
284 }
285 })?;
286 }
287 Action::SwitchScene(scene) => {
288 info!("Scene switched to: {scene:?}");
289 self.scene = scene;
290 }
291 Action::SwitchInputMode(mode) => {
292 info!("Input mode switched to: {mode:?}");
293 self.input_mode = mode;
294 }
295 Action::StoreStorageDrive(ref drive_mountpoint, ref drive_name) => {
297 debug!("Storing storage drive: {drive_mountpoint:?}, {drive_name:?}");
298 self.app_data.storage_mountpoint = Some(drive_mountpoint.clone());
299 self.app_data.storage_drive = Some(drive_name.as_str().to_string());
300 self.app_data.save(None)?;
301 }
302 Action::StoreConnectionMode(ref mode) => {
303 debug!("Storing connection mode: {mode:?}");
304 self.app_data.connection_mode = Some(*mode);
305 self.app_data.save(None)?;
306 }
307 Action::StorePortRange(ref from, ref to) => {
308 debug!("Storing port range: {from:?}, {to:?}");
309 self.app_data.port_from = Some(*from);
310 self.app_data.port_to = Some(*to);
311 self.app_data.save(None)?;
312 }
313 Action::StoreRewardsAddress(ref rewards_address) => {
314 debug!("Storing rewards address: {rewards_address:?}");
315 self.app_data.discord_username.clone_from(rewards_address);
316 self.app_data.save(None)?;
317 }
318 Action::StoreNodesToStart(ref count) => {
319 debug!("Storing nodes to start: {count:?}");
320 self.app_data.nodes_to_start = *count;
321 self.app_data.save(None)?;
322 }
323 _ => {}
324 }
325 for component in self.components.iter_mut() {
326 if let Some(action) = component.update(action.clone())? {
327 action_tx.send(action)?
328 };
329 }
330 }
331 if self.should_suspend {
332 tui.suspend()?;
333 action_tx.send(Action::Resume)?;
334 tui = tui::Tui::new()?
335 .tick_rate(self.tick_rate)
336 .frame_rate(self.frame_rate);
337 tui.enter()?;
339 } else if self.should_quit {
340 tui.stop()?;
341 break;
342 }
343 }
344 tui.exit()?;
345 info!("Exiting application");
346 Ok(())
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use ant_bootstrap::InitialPeersConfig;
354 use color_eyre::eyre::Result;
355 use serde_json::json;
356 use std::io::Cursor;
357 use std::io::Write;
358 use tempfile::tempdir;
359
360 #[tokio::test]
361 async fn test_app_creation_with_valid_config() -> Result<()> {
362 let temp_dir = tempdir()?;
364 let config_path = temp_dir.path().join("valid_config.json");
365
366 let mountpoint = get_primary_mount_point();
367
368 let config = json!({
369 "discord_username": "happy_user",
370 "nodes_to_start": 5,
371 "storage_mountpoint": mountpoint.display().to_string(),
372 "storage_drive": "C:",
373 "connection_mode": "Automatic",
374 "port_from": 12000,
375 "port_to": 13000
376 });
377
378 let valid_config = serde_json::to_string_pretty(&config)?;
379 std::fs::write(&config_path, valid_config)?;
380
381 let init_peers_config = InitialPeersConfig::default();
383
384 let mut output = Cursor::new(Vec::new());
386
387 let app_result =
389 App::new(60.0, 60.0, init_peers_config, None, Some(config_path), None).await;
390
391 match app_result {
392 Ok(app) => {
393 assert_eq!(app.app_data.discord_username, "happy_user");
395 assert_eq!(app.app_data.nodes_to_start, 5);
396 assert_eq!(app.app_data.storage_mountpoint, Some(mountpoint));
397 assert_eq!(app.app_data.storage_drive, Some("C:".to_string()));
398 assert_eq!(
399 app.app_data.connection_mode,
400 Some(ConnectionMode::Automatic)
401 );
402 assert_eq!(app.app_data.port_from, Some(12000));
403 assert_eq!(app.app_data.port_to, Some(13000));
404
405 write!(output, "App created successfully with valid configuration")?;
406 }
407 Err(e) => {
408 write!(output, "App creation failed: {e}")?;
409 }
410 }
411
412 let output_str = String::from_utf8(output.into_inner())?;
414
415 assert!(
417 output_str.contains("App created successfully with valid configuration"),
418 "Unexpected output: {output_str}"
419 );
420
421 Ok(())
422 }
423
424 #[tokio::test]
425 async fn test_app_should_run_when_storage_mountpoint_not_set() -> Result<()> {
426 let temp_dir = tempdir()?;
428 let test_app_data_path = temp_dir.path().join("test_app_data.json");
429
430 let custom_config = r#"
432 {
433 "discord_username": "test_user",
434 "nodes_to_start": 3,
435 "connection_mode": "Custom Ports",
436 "port_from": 12000,
437 "port_to": 13000
438 }
439 "#;
440 std::fs::write(&test_app_data_path, custom_config)?;
441
442 let init_peers_config = InitialPeersConfig::default();
444
445 let mut output = Cursor::new(Vec::new());
447
448 let app_result = App::new(
450 60.0,
451 60.0,
452 init_peers_config,
453 None,
454 Some(test_app_data_path),
455 None,
456 )
457 .await;
458
459 match app_result {
460 Ok(app) => {
461 assert_eq!(app.app_data.discord_username, "test_user");
463 assert_eq!(app.app_data.nodes_to_start, 3);
464 assert!(app.app_data.storage_mountpoint.is_some());
466 assert!(app.app_data.storage_drive.is_some());
468 assert_eq!(
470 app.app_data.connection_mode,
471 Some(ConnectionMode::CustomPorts)
472 );
473 assert_eq!(app.app_data.port_from, Some(12000));
474 assert_eq!(app.app_data.port_to, Some(13000));
475
476 write!(
477 output,
478 "App created successfully with partial configuration"
479 )?;
480 }
481 Err(e) => {
482 write!(output, "App creation failed: {e}")?;
483 }
484 }
485
486 let output_str = String::from_utf8(output.into_inner())?;
488
489 assert!(
491 output_str.contains("App created successfully with partial configuration"),
492 "Unexpected output: {output_str}"
493 );
494
495 Ok(())
496 }
497
498 #[tokio::test]
499 async fn test_app_creation_when_config_file_doesnt_exist() -> Result<()> {
500 let temp_dir = tempdir()?;
502 let non_existent_config_path = temp_dir.path().join("non_existent_config.json");
503
504 let init_peers_config = InitialPeersConfig::default();
506
507 let mut output = Cursor::new(Vec::new());
509
510 let app_result = App::new(
512 60.0,
513 60.0,
514 init_peers_config,
515 None,
516 Some(non_existent_config_path),
517 None,
518 )
519 .await;
520
521 match app_result {
522 Ok(app) => {
523 assert_eq!(app.app_data.discord_username, "");
524 assert_eq!(app.app_data.nodes_to_start, 1);
525 assert!(app.app_data.storage_mountpoint.is_some());
526 assert!(app.app_data.storage_drive.is_some());
527 assert_eq!(
528 app.app_data.connection_mode,
529 Some(ConnectionMode::Automatic)
530 );
531 assert_eq!(app.app_data.port_from, Some(PORT_MIN));
532 assert_eq!(app.app_data.port_to, Some(PORT_MAX));
533
534 write!(
535 output,
536 "App created successfully with default configuration"
537 )?;
538 }
539 Err(e) => {
540 write!(output, "App creation failed: {e}")?;
541 }
542 }
543
544 let output_str = String::from_utf8(output.into_inner())?;
546
547 assert!(
549 output_str.contains("App created successfully with default configuration"),
550 "Unexpected output: {output_str}"
551 );
552
553 Ok(())
554 }
555
556 #[tokio::test]
557 async fn test_app_creation_with_invalid_storage_mountpoint() -> Result<()> {
558 let temp_dir = tempdir()?;
560 let config_path = temp_dir.path().join("invalid_config.json");
561
562 let invalid_config = r#"
564 {
565 "discord_username": "test_user",
566 "nodes_to_start": 5,
567 "storage_mountpoint": "/non/existent/path",
568 "storage_drive": "Z:",
569 "connection_mode": "Custom Ports",
570 "port_from": 12000,
571 "port_to": 13000
572 }
573 "#;
574 std::fs::write(&config_path, invalid_config)?;
575
576 let init_peers_config = InitialPeersConfig::default();
578
579 let app_result =
581 App::new(60.0, 60.0, init_peers_config, None, Some(config_path), None).await;
582
583 match app_result {
586 Ok(_) => {
587 panic!("App creation should have failed due to invalid storage_mountpoint");
588 }
589 Err(e) => {
590 assert!(
591 e.to_string().contains(
592 "Cannot find the primary disk. Configuration file might be wrong."
593 ) || e.to_string().contains("Failed to create nodes data dir in"),
594 "Unexpected error message: {e}"
595 );
596 }
597 }
598
599 Ok(())
600 }
601
602 #[tokio::test]
603 async fn test_app_default_connection_mode_and_ports() -> Result<()> {
604 let temp_dir = tempdir()?;
606 let test_app_data_path = temp_dir.path().join("test_app_data.json");
607
608 let custom_config = r#"
610 {
611 "discord_username": "test_user",
612 "nodes_to_start": 3
613 }
614 "#;
615 std::fs::write(&test_app_data_path, custom_config)?;
616
617 let init_peers_config = InitialPeersConfig::default();
619
620 let app_result = App::new(
622 60.0,
623 60.0,
624 init_peers_config,
625 None,
626 Some(test_app_data_path),
627 None,
628 )
629 .await;
630
631 match app_result {
632 Ok(app) => {
633 assert_eq!(app.app_data.discord_username, "test_user");
635 assert_eq!(app.app_data.nodes_to_start, 3);
636
637 assert_eq!(
639 app.app_data.connection_mode,
640 Some(ConnectionMode::Automatic)
641 );
642
643 assert_eq!(app.app_data.port_from, Some(PORT_MIN));
645 assert_eq!(app.app_data.port_to, Some(PORT_MAX));
646
647 println!("App created successfully with default connection mode and ports");
648 }
649 Err(e) => {
650 panic!("App creation failed: {e}");
651 }
652 }
653
654 Ok(())
655 }
656}