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, remove_node::RemoveNodePopUp,
20 reset_nodes::ResetNodesPopup, rewards_address::RewardsAddress,
21 upgrade_nodes::UpgradeNodesPopUp,
22 },
23 status::{Status, StatusConfig},
24 Component,
25 },
26 config::{get_launchpad_nodes_data_dir_path, AppData, Config},
27 connection_mode::ConnectionMode,
28 mode::{InputMode, Scene},
29 node_mgmt::{PORT_MAX, PORT_MIN},
30 style::SPACE_CADET,
31 system::{get_default_mount_point, get_primary_mount_point, get_primary_mount_point_name},
32 tui,
33};
34use ant_bootstrap::InitialPeersConfig;
35use color_eyre::eyre::Result;
36use crossterm::event::KeyEvent;
37use ratatui::{prelude::Rect, style::Style, widgets::Block};
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
135 Ok(Self {
136 config,
137 app_data: AppData {
138 discord_username: app_data.discord_username.clone(),
139 nodes_to_start: app_data.nodes_to_start,
140 storage_mountpoint: Some(storage_mountpoint),
141 storage_drive: Some(storage_drive),
142 connection_mode: Some(connection_mode),
143 port_from: Some(port_from),
144 port_to: Some(port_to),
145 },
146 tick_rate,
147 frame_rate,
148 components: vec![
149 Box::new(status),
151 Box::new(options),
152 Box::new(help),
153 Box::new(change_drive),
155 Box::new(change_connection_mode),
156 Box::new(port_range),
157 Box::new(rewards_address),
158 Box::new(reset_nodes),
159 Box::new(manage_nodes),
160 Box::new(upgrade_nodes),
161 Box::new(remove_node),
162 ],
163 should_quit: false,
164 should_suspend: false,
165 input_mode: InputMode::Navigation,
166 scene: Scene::Status,
167 last_tick_key_events: Vec::new(),
168 })
169 }
170
171 pub async fn run(&mut self) -> Result<()> {
172 let (action_tx, mut action_rx) = mpsc::unbounded_channel();
173
174 let action_tx_clone = action_tx.clone();
175
176 tokio::spawn(async move {
177 let upnp_support = tokio::task::spawn_blocking(get_upnp_support)
178 .await
179 .unwrap_or(UpnpSupport::Unknown);
180
181 let _ = action_tx_clone.send(Action::SetUpnpSupport(upnp_support));
182 });
183
184 let mut tui = tui::Tui::new()?
185 .tick_rate(self.tick_rate)
186 .frame_rate(self.frame_rate);
187 tui.enter()?;
189
190 for component in self.components.iter_mut() {
191 component.register_action_handler(action_tx.clone())?;
192 component.register_config_handler(self.config.clone())?;
193 let size = tui.size()?;
194 let rect = Rect::new(0, 0, size.width, size.height);
195 component.init(rect)?;
196 }
197
198 loop {
199 if let Some(e) = tui.next().await {
200 match e {
201 tui::Event::Quit => action_tx.send(Action::Quit)?,
202 tui::Event::Tick => action_tx.send(Action::Tick)?,
203 tui::Event::Render => action_tx.send(Action::Render)?,
204 tui::Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
205 tui::Event::Key(key) => {
206 if self.input_mode == InputMode::Navigation {
207 if let Some(keymap) = self.config.keybindings.get(&self.scene) {
208 if let Some(action) = keymap.get(&vec![key]) {
209 info!("Got action: {action:?}");
210 action_tx.send(action.clone())?;
211 } else {
212 self.last_tick_key_events.push(key);
215
216 if let Some(action) = keymap.get(&self.last_tick_key_events) {
218 info!("Got action: {action:?}");
219 action_tx.send(action.clone())?;
220 }
221 }
222 };
223 } else if self.input_mode == InputMode::Entry {
224 for component in self.components.iter_mut() {
225 let send_back_actions = component.handle_events(Some(e.clone()))?;
226 for action in send_back_actions {
227 action_tx.send(action)?;
228 }
229 }
230 }
231 }
232 _ => {}
233 }
234 }
235
236 while let Ok(action) = action_rx.try_recv() {
237 if action != Action::Tick && action != Action::Render {
238 debug!("{action:?}");
239 }
240 match action {
241 Action::Tick => {
242 self.last_tick_key_events.drain(..);
243 }
244 Action::Quit => self.should_quit = true,
245 Action::Suspend => self.should_suspend = true,
246 Action::Resume => self.should_suspend = false,
247 Action::Resize(w, h) => {
248 tui.resize(Rect::new(0, 0, w, h))?;
249 tui.draw(|f| {
250 for component in self.components.iter_mut() {
251 let r = component.draw(f, f.area());
252 if let Err(e) = r {
253 action_tx
254 .send(Action::Error(format!("Failed to draw: {:?}", e)))
255 .unwrap();
256 }
257 }
258 })?;
259 }
260 Action::Render => {
261 tui.draw(|f| {
262 f.render_widget(
263 Block::new().style(Style::new().bg(SPACE_CADET)),
264 f.area(),
265 );
266 for component in self.components.iter_mut() {
267 let r = component.draw(f, f.area());
268 if let Err(e) = r {
269 action_tx
270 .send(Action::Error(format!("Failed to draw: {:?}", e)))
271 .unwrap();
272 }
273 }
274 })?;
275 }
276 Action::SwitchScene(scene) => {
277 info!("Scene switched to: {scene:?}");
278 self.scene = scene;
279 }
280 Action::SwitchInputMode(mode) => {
281 info!("Input mode switched to: {mode:?}");
282 self.input_mode = mode;
283 }
284 Action::StoreStorageDrive(ref drive_mountpoint, ref drive_name) => {
286 debug!("Storing storage drive: {drive_mountpoint:?}, {drive_name:?}");
287 self.app_data.storage_mountpoint = Some(drive_mountpoint.clone());
288 self.app_data.storage_drive = Some(drive_name.as_str().to_string());
289 self.app_data.save(None)?;
290 }
291 Action::StoreConnectionMode(ref mode) => {
292 debug!("Storing connection mode: {mode:?}");
293 self.app_data.connection_mode = Some(*mode);
294 self.app_data.save(None)?;
295 }
296 Action::StorePortRange(ref from, ref to) => {
297 debug!("Storing port range: {from:?}, {to:?}");
298 self.app_data.port_from = Some(*from);
299 self.app_data.port_to = Some(*to);
300 self.app_data.save(None)?;
301 }
302 Action::StoreRewardsAddress(ref rewards_address) => {
303 debug!("Storing rewards address: {rewards_address:?}");
304 self.app_data.discord_username.clone_from(rewards_address);
305 self.app_data.save(None)?;
306 }
307 Action::StoreNodesToStart(ref count) => {
308 debug!("Storing nodes to start: {count:?}");
309 self.app_data.nodes_to_start = *count;
310 self.app_data.save(None)?;
311 }
312 _ => {}
313 }
314 for component in self.components.iter_mut() {
315 if let Some(action) = component.update(action.clone())? {
316 action_tx.send(action)?
317 };
318 }
319 }
320 if self.should_suspend {
321 tui.suspend()?;
322 action_tx.send(Action::Resume)?;
323 tui = tui::Tui::new()?
324 .tick_rate(self.tick_rate)
325 .frame_rate(self.frame_rate);
326 tui.enter()?;
328 } else if self.should_quit {
329 tui.stop()?;
330 break;
331 }
332 }
333 tui.exit()?;
334 Ok(())
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use ant_bootstrap::InitialPeersConfig;
342 use color_eyre::eyre::Result;
343 use serde_json::json;
344 use std::io::Cursor;
345 use std::io::Write;
346 use tempfile::tempdir;
347
348 #[tokio::test]
349 async fn test_app_creation_with_valid_config() -> Result<()> {
350 let temp_dir = tempdir()?;
352 let config_path = temp_dir.path().join("valid_config.json");
353
354 let mountpoint = get_primary_mount_point();
355
356 let config = json!({
357 "discord_username": "happy_user",
358 "nodes_to_start": 5,
359 "storage_mountpoint": mountpoint.display().to_string(),
360 "storage_drive": "C:",
361 "connection_mode": "Automatic",
362 "port_from": 12000,
363 "port_to": 13000
364 });
365
366 let valid_config = serde_json::to_string_pretty(&config)?;
367 std::fs::write(&config_path, valid_config)?;
368
369 let init_peers_config = InitialPeersConfig::default();
371
372 let mut output = Cursor::new(Vec::new());
374
375 let app_result =
377 App::new(60.0, 60.0, init_peers_config, None, Some(config_path), None).await;
378
379 match app_result {
380 Ok(app) => {
381 assert_eq!(app.app_data.discord_username, "happy_user");
383 assert_eq!(app.app_data.nodes_to_start, 5);
384 assert_eq!(app.app_data.storage_mountpoint, Some(mountpoint));
385 assert_eq!(app.app_data.storage_drive, Some("C:".to_string()));
386 assert_eq!(
387 app.app_data.connection_mode,
388 Some(ConnectionMode::Automatic)
389 );
390 assert_eq!(app.app_data.port_from, Some(12000));
391 assert_eq!(app.app_data.port_to, Some(13000));
392
393 write!(output, "App created successfully with valid configuration")?;
394 }
395 Err(e) => {
396 write!(output, "App creation failed: {}", e)?;
397 }
398 }
399
400 let output_str = String::from_utf8(output.into_inner())?;
402
403 assert!(
405 output_str.contains("App created successfully with valid configuration"),
406 "Unexpected output: {}",
407 output_str
408 );
409
410 Ok(())
411 }
412
413 #[tokio::test]
414 async fn test_app_should_run_when_storage_mountpoint_not_set() -> Result<()> {
415 let temp_dir = tempdir()?;
417 let test_app_data_path = temp_dir.path().join("test_app_data.json");
418
419 let custom_config = r#"
421 {
422 "discord_username": "test_user",
423 "nodes_to_start": 3,
424 "connection_mode": "Custom Ports",
425 "port_from": 12000,
426 "port_to": 13000
427 }
428 "#;
429 std::fs::write(&test_app_data_path, custom_config)?;
430
431 let init_peers_config = InitialPeersConfig::default();
433
434 let mut output = Cursor::new(Vec::new());
436
437 let app_result = App::new(
439 60.0,
440 60.0,
441 init_peers_config,
442 None,
443 Some(test_app_data_path),
444 None,
445 )
446 .await;
447
448 match app_result {
449 Ok(app) => {
450 assert_eq!(app.app_data.discord_username, "test_user");
452 assert_eq!(app.app_data.nodes_to_start, 3);
453 assert!(app.app_data.storage_mountpoint.is_some());
455 assert!(app.app_data.storage_drive.is_some());
457 assert_eq!(
459 app.app_data.connection_mode,
460 Some(ConnectionMode::CustomPorts)
461 );
462 assert_eq!(app.app_data.port_from, Some(12000));
463 assert_eq!(app.app_data.port_to, Some(13000));
464
465 write!(
466 output,
467 "App created successfully with partial configuration"
468 )?;
469 }
470 Err(e) => {
471 write!(output, "App creation failed: {}", e)?;
472 }
473 }
474
475 let output_str = String::from_utf8(output.into_inner())?;
477
478 assert!(
480 output_str.contains("App created successfully with partial configuration"),
481 "Unexpected output: {}",
482 output_str
483 );
484
485 Ok(())
486 }
487
488 #[tokio::test]
489 async fn test_app_creation_when_config_file_doesnt_exist() -> Result<()> {
490 let temp_dir = tempdir()?;
492 let non_existent_config_path = temp_dir.path().join("non_existent_config.json");
493
494 let init_peers_config = InitialPeersConfig::default();
496
497 let mut output = Cursor::new(Vec::new());
499
500 let app_result = App::new(
502 60.0,
503 60.0,
504 init_peers_config,
505 None,
506 Some(non_existent_config_path),
507 None,
508 )
509 .await;
510
511 match app_result {
512 Ok(app) => {
513 assert_eq!(app.app_data.discord_username, "");
514 assert_eq!(app.app_data.nodes_to_start, 1);
515 assert!(app.app_data.storage_mountpoint.is_some());
516 assert!(app.app_data.storage_drive.is_some());
517 assert_eq!(
518 app.app_data.connection_mode,
519 Some(ConnectionMode::Automatic)
520 );
521 assert_eq!(app.app_data.port_from, Some(PORT_MIN));
522 assert_eq!(app.app_data.port_to, Some(PORT_MAX));
523
524 write!(
525 output,
526 "App created successfully with default configuration"
527 )?;
528 }
529 Err(e) => {
530 write!(output, "App creation failed: {}", e)?;
531 }
532 }
533
534 let output_str = String::from_utf8(output.into_inner())?;
536
537 assert!(
539 output_str.contains("App created successfully with default configuration"),
540 "Unexpected output: {}",
541 output_str
542 );
543
544 Ok(())
545 }
546
547 #[tokio::test]
548 async fn test_app_creation_with_invalid_storage_mountpoint() -> Result<()> {
549 let temp_dir = tempdir()?;
551 let config_path = temp_dir.path().join("invalid_config.json");
552
553 let invalid_config = r#"
555 {
556 "discord_username": "test_user",
557 "nodes_to_start": 5,
558 "storage_mountpoint": "/non/existent/path",
559 "storage_drive": "Z:",
560 "connection_mode": "Custom Ports",
561 "port_from": 12000,
562 "port_to": 13000
563 }
564 "#;
565 std::fs::write(&config_path, invalid_config)?;
566
567 let init_peers_config = InitialPeersConfig::default();
569
570 let app_result =
572 App::new(60.0, 60.0, init_peers_config, None, Some(config_path), None).await;
573
574 match app_result {
577 Ok(_) => {
578 panic!("App creation should have failed due to invalid storage_mountpoint");
579 }
580 Err(e) => {
581 assert!(
582 e.to_string().contains(
583 "Cannot find the primary disk. Configuration file might be wrong."
584 ) || e.to_string().contains("Failed to create nodes data dir in"),
585 "Unexpected error message: {}",
586 e
587 );
588 }
589 }
590
591 Ok(())
592 }
593
594 #[tokio::test]
595 async fn test_app_default_connection_mode_and_ports() -> Result<()> {
596 let temp_dir = tempdir()?;
598 let test_app_data_path = temp_dir.path().join("test_app_data.json");
599
600 let custom_config = r#"
602 {
603 "discord_username": "test_user",
604 "nodes_to_start": 3
605 }
606 "#;
607 std::fs::write(&test_app_data_path, custom_config)?;
608
609 let init_peers_config = InitialPeersConfig::default();
611
612 let app_result = App::new(
614 60.0,
615 60.0,
616 init_peers_config,
617 None,
618 Some(test_app_data_path),
619 None,
620 )
621 .await;
622
623 match app_result {
624 Ok(app) => {
625 assert_eq!(app.app_data.discord_username, "test_user");
627 assert_eq!(app.app_data.nodes_to_start, 3);
628
629 assert_eq!(
631 app.app_data.connection_mode,
632 Some(ConnectionMode::Automatic)
633 );
634
635 assert_eq!(app.app_data.port_from, Some(PORT_MIN));
637 assert_eq!(app.app_data.port_to, Some(PORT_MAX));
638
639 println!("App created successfully with default connection mode and ports");
640 }
641 Err(e) => {
642 panic!("App creation failed: {}", e);
643 }
644 }
645
646 Ok(())
647 }
648}