node_launchpad/
app.rs

1// Copyright 2024 MaidSafe.net limited.
2//
3// This SAFE Network Software is licensed to you under The General Public License (GPL), version 3.
4// Unless required by applicable law or agreed to in writing, the SAFE Network Software distributed
5// under the GPL Licence is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
6// KIND, either express or implied. Please review the Licences for the specific language governing
7// permissions and limitations relating to use of the SAFE Network Software.
8
9use 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        // Configurations
62        let app_data = AppData::load(app_data_path)?;
63        let config = Config::new()?;
64
65        // Tries to set the data dir path based on the storage mountpoint set by the user,
66        // if not set, it tries to get the default mount point (where the executable is) and
67        // create the nodes data dir there.
68        // If even that fails, it will create the nodes data dir in the primary mount point.
69        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        // App data default values
79        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        // Main Screens
97        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        // Popups
123        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                // Sections
147                Box::new(status),
148                Box::new(options),
149                Box::new(help),
150                // Popups
151                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.mouse(true);
184        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                                    // If the key was not handled as a single key action,
209                                    // then consider it for multi-key combinations.
210                                    self.last_tick_key_events.push(key);
211
212                                    // Check for multi-key combinations
213                                    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                    // Storing Application Data
281                    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.mouse(true);
323                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        // Create a temporary directory for our test
347        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        // Create default PeersArgs
366        let init_peers_config = InitialPeersConfig::default();
367
368        // Create a buffer to capture output
369        let mut output = Cursor::new(Vec::new());
370
371        // Create and run the App, capturing its output
372        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                // Check if all fields were correctly loaded
378                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        // Convert captured output to string
397        let output_str = String::from_utf8(output.into_inner())?;
398
399        // Check if the success message is in the output
400        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        // Create a temporary directory for our test
412        let temp_dir = tempdir()?;
413        let test_app_data_path = temp_dir.path().join("test_app_data.json");
414
415        // Create a custom configuration file with only some settings
416        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        // Create default PeersArgs
428        let init_peers_config = InitialPeersConfig::default();
429
430        // Create a buffer to capture output
431        let mut output = Cursor::new(Vec::new());
432
433        // Create and run the App, capturing its output
434        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                // Check if the fields were correctly loaded
447                assert_eq!(app.app_data.discord_username, "test_user");
448                assert_eq!(app.app_data.nodes_to_start, 3);
449                // Check if the storage_mountpoint is Some (automatically set)
450                assert!(app.app_data.storage_mountpoint.is_some());
451                // Check if the storage_drive is Some (automatically set)
452                assert!(app.app_data.storage_drive.is_some());
453                // Check the new fields
454                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        // Convert captured output to string
472        let output_str = String::from_utf8(output.into_inner())?;
473
474        // Check if the success message is in the output
475        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        // Create a temporary directory for our test
487        let temp_dir = tempdir()?;
488        let non_existent_config_path = temp_dir.path().join("non_existent_config.json");
489
490        // Create default PeersArgs
491        let init_peers_config = InitialPeersConfig::default();
492
493        // Create a buffer to capture output
494        let mut output = Cursor::new(Vec::new());
495
496        // Create and run the App, capturing its output
497        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        // Convert captured output to string
531        let output_str = String::from_utf8(output.into_inner())?;
532
533        // Check if the success message is in the output
534        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        // Create a temporary directory for our test
546        let temp_dir = tempdir()?;
547        let config_path = temp_dir.path().join("invalid_config.json");
548
549        // Create a configuration file with an invalid storage_mountpoint
550        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        // Create default PeersArgs
564        let init_peers_config = InitialPeersConfig::default();
565
566        // Create and run the App, capturing its output
567        let app_result =
568            App::new(60.0, 60.0, init_peers_config, None, Some(config_path), None).await;
569
570        // Could be that the mountpoint doesn't exists
571        // or that the user doesn't have permissions to access it
572        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        // Create a temporary directory for our test
593        let temp_dir = tempdir()?;
594        let test_app_data_path = temp_dir.path().join("test_app_data.json");
595
596        // Create a custom configuration file without connection mode and ports
597        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        // Create default PeersArgs
606        let init_peers_config = InitialPeersConfig::default();
607
608        // Create and run the App
609        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                // Check if the discord_username and nodes_to_start were correctly loaded
622                assert_eq!(app.app_data.discord_username, "test_user");
623                assert_eq!(app.app_data.nodes_to_start, 3);
624
625                // Check if the connection_mode is set to the default (Automatic)
626                assert_eq!(
627                    app.app_data.connection_mode,
628                    Some(ConnectionMode::Automatic)
629                );
630
631                // Check if the port range is set to the default values
632                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}