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, 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        // 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
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                // Sections
150                Box::new(status),
151                Box::new(options),
152                Box::new(help),
153                // Popups
154                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.mouse(true);
188        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                                    // If the key was not handled as a single key action,
213                                    // then consider it for multi-key combinations.
214                                    self.last_tick_key_events.push(key);
215
216                                    // Check for multi-key combinations
217                                    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                    // Storing Application Data
285                    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.mouse(true);
327                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        // Create a temporary directory for our test
351        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        // Create default PeersArgs
370        let init_peers_config = InitialPeersConfig::default();
371
372        // Create a buffer to capture output
373        let mut output = Cursor::new(Vec::new());
374
375        // Create and run the App, capturing its output
376        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                // Check if all fields were correctly loaded
382                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        // Convert captured output to string
401        let output_str = String::from_utf8(output.into_inner())?;
402
403        // Check if the success message is in the output
404        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        // Create a temporary directory for our test
416        let temp_dir = tempdir()?;
417        let test_app_data_path = temp_dir.path().join("test_app_data.json");
418
419        // Create a custom configuration file with only some settings
420        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        // Create default PeersArgs
432        let init_peers_config = InitialPeersConfig::default();
433
434        // Create a buffer to capture output
435        let mut output = Cursor::new(Vec::new());
436
437        // Create and run the App, capturing its output
438        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                // Check if the fields were correctly loaded
451                assert_eq!(app.app_data.discord_username, "test_user");
452                assert_eq!(app.app_data.nodes_to_start, 3);
453                // Check if the storage_mountpoint is Some (automatically set)
454                assert!(app.app_data.storage_mountpoint.is_some());
455                // Check if the storage_drive is Some (automatically set)
456                assert!(app.app_data.storage_drive.is_some());
457                // Check the new fields
458                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        // Convert captured output to string
476        let output_str = String::from_utf8(output.into_inner())?;
477
478        // Check if the success message is in the output
479        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        // Create a temporary directory for our test
491        let temp_dir = tempdir()?;
492        let non_existent_config_path = temp_dir.path().join("non_existent_config.json");
493
494        // Create default PeersArgs
495        let init_peers_config = InitialPeersConfig::default();
496
497        // Create a buffer to capture output
498        let mut output = Cursor::new(Vec::new());
499
500        // Create and run the App, capturing its output
501        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        // Convert captured output to string
535        let output_str = String::from_utf8(output.into_inner())?;
536
537        // Check if the success message is in the output
538        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        // Create a temporary directory for our test
550        let temp_dir = tempdir()?;
551        let config_path = temp_dir.path().join("invalid_config.json");
552
553        // Create a configuration file with an invalid storage_mountpoint
554        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        // Create default PeersArgs
568        let init_peers_config = InitialPeersConfig::default();
569
570        // Create and run the App, capturing its output
571        let app_result =
572            App::new(60.0, 60.0, init_peers_config, None, Some(config_path), None).await;
573
574        // Could be that the mountpoint doesn't exists
575        // or that the user doesn't have permissions to access it
576        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        // Create a temporary directory for our test
597        let temp_dir = tempdir()?;
598        let test_app_data_path = temp_dir.path().join("test_app_data.json");
599
600        // Create a custom configuration file without connection mode and ports
601        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        // Create default PeersArgs
610        let init_peers_config = InitialPeersConfig::default();
611
612        // Create and run the App
613        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                // Check if the discord_username and nodes_to_start were correctly loaded
626                assert_eq!(app.app_data.discord_username, "test_user");
627                assert_eq!(app.app_data.nodes_to_start, 3);
628
629                // Check if the connection_mode is set to the default (Automatic)
630                assert_eq!(
631                    app.app_data.connection_mode,
632                    Some(ConnectionMode::Automatic)
633                );
634
635                // Check if the port range is set to the default values
636                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}