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 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        // Configurations
63        let app_data = AppData::load(app_data_path)?;
64        let config = Config::new()?;
65
66        // Tries to set the data dir path based on the storage mountpoint set by the user,
67        // if not set, it tries to get the default mount point (where the executable is) and
68        // create the nodes data dir there.
69        // If even that fails, it will create the nodes data dir in the primary mount point.
70        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        // App data default values
80        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        // Main Screens
98        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        // Popups
125        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            // Sections
138            Box::new(status),
139            Box::new(options),
140            Box::new(help),
141            // Popups
142            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.mouse(true);
189        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                                    // If the key was not handled as a single key action,
219                                    // then consider it for multi-key combinations.
220                                    self.last_tick_key_events.push(key);
221
222                                    // Check for multi-key combinations
223                                    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                    // Storing Application Data
296                    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.mouse(true);
338                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        // Create a temporary directory for our test
363        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        // Create default PeersArgs
382        let init_peers_config = InitialPeersConfig::default();
383
384        // Create a buffer to capture output
385        let mut output = Cursor::new(Vec::new());
386
387        // Create and run the App, capturing its output
388        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                // Check if all fields were correctly loaded
394                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        // Convert captured output to string
413        let output_str = String::from_utf8(output.into_inner())?;
414
415        // Check if the success message is in the output
416        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        // Create a temporary directory for our test
427        let temp_dir = tempdir()?;
428        let test_app_data_path = temp_dir.path().join("test_app_data.json");
429
430        // Create a custom configuration file with only some settings
431        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        // Create default PeersArgs
443        let init_peers_config = InitialPeersConfig::default();
444
445        // Create a buffer to capture output
446        let mut output = Cursor::new(Vec::new());
447
448        // Create and run the App, capturing its output
449        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                // Check if the fields were correctly loaded
462                assert_eq!(app.app_data.discord_username, "test_user");
463                assert_eq!(app.app_data.nodes_to_start, 3);
464                // Check if the storage_mountpoint is Some (automatically set)
465                assert!(app.app_data.storage_mountpoint.is_some());
466                // Check if the storage_drive is Some (automatically set)
467                assert!(app.app_data.storage_drive.is_some());
468                // Check the new fields
469                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        // Convert captured output to string
487        let output_str = String::from_utf8(output.into_inner())?;
488
489        // Check if the success message is in the output
490        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        // Create a temporary directory for our test
501        let temp_dir = tempdir()?;
502        let non_existent_config_path = temp_dir.path().join("non_existent_config.json");
503
504        // Create default PeersArgs
505        let init_peers_config = InitialPeersConfig::default();
506
507        // Create a buffer to capture output
508        let mut output = Cursor::new(Vec::new());
509
510        // Create and run the App, capturing its output
511        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        // Convert captured output to string
545        let output_str = String::from_utf8(output.into_inner())?;
546
547        // Check if the success message is in the output
548        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        // Create a temporary directory for our test
559        let temp_dir = tempdir()?;
560        let config_path = temp_dir.path().join("invalid_config.json");
561
562        // Create a configuration file with an invalid storage_mountpoint
563        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        // Create default PeersArgs
577        let init_peers_config = InitialPeersConfig::default();
578
579        // Create and run the App, capturing its output
580        let app_result =
581            App::new(60.0, 60.0, init_peers_config, None, Some(config_path), None).await;
582
583        // Could be that the mountpoint doesn't exists
584        // or that the user doesn't have permissions to access it
585        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        // Create a temporary directory for our test
605        let temp_dir = tempdir()?;
606        let test_app_data_path = temp_dir.path().join("test_app_data.json");
607
608        // Create a custom configuration file without connection mode and ports
609        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        // Create default PeersArgs
618        let init_peers_config = InitialPeersConfig::default();
619
620        // Create and run the App
621        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                // Check if the discord_username and nodes_to_start were correctly loaded
634                assert_eq!(app.app_data.discord_username, "test_user");
635                assert_eq!(app.app_data.nodes_to_start, 3);
636
637                // Check if the connection_mode is set to the default (Automatic)
638                assert_eq!(
639                    app.app_data.connection_mode,
640                    Some(ConnectionMode::Automatic)
641                );
642
643                // Check if the port range is set to the default values
644                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}