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