diff --git a/CHANGELOG.md b/CHANGELOG.md index 187e9fa..c92fd49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,10 @@ Released on +- [**Multi Host support**](https://github.com/veeso/termscp/issues/285): + - Now it is possible to work on two different remotes `remote A -> remote B` instead of just `localhost -> remote` + - Cli arguments now accept an additional `remote-args` for the left panel. + - For more details read this issue . - [Issue 290](https://github.com/veeso/termscp/issues/290): Password prompt was broken ## 0.15.0 diff --git a/docs/de/man.md b/docs/de/man.md index e4b731b..4af14e2 100644 --- a/docs/de/man.md +++ b/docs/de/man.md @@ -62,11 +62,11 @@ termscp kann mit den folgenden Optionen gestartet werden: -`termscp [Optionen]... [protokoll://benutzer@adresse:port:arbeitsverzeichnis] [lokales-arbeitsverzeichnis]` +`termscp [Optionen]... [protokoll://benutzer@adresse:port:arbeitsverzeichnis] [protokoll://benutzer@adresse:port:arbeitsverzeichnis] [lokales-arbeitsverzeichnis]` ODER -`termscp [Optionen]... -b [Lesezeichen-Name] [lokales-arbeitsverzeichnis]` +`termscp [Optionen]... -b [Lesezeichen-Name] -b [Lesezeichen-Name] [lokales-arbeitsverzeichnis]` - `-P, --password ` wenn Adresse angegeben wird, ist das Passwort dieses Argument - `-b, --address-as-bookmark` löst das Adressargument als Lesezeichenname auf diff --git a/docs/es/man.md b/docs/es/man.md index 81788a6..b1bfb8b 100644 --- a/docs/es/man.md +++ b/docs/es/man.md @@ -39,11 +39,11 @@ termscp se puede iniciar con las siguientes opciones: -`termscp [options]... [protocol://user@address:port:wrkdir] [local-wrkdir]` +`termscp [options]... [protocol://user@address:port:wrkdir] [protocol://user@address:port:wrkdir] [local-wrkdir]` OR -`termscp [options]... -b [bookmark-name] [local-wrkdir]` +`termscp [options]... -b [bookmark-name] -b [bookmark-name] [local-wrkdir]` - `-P, --password ` si se proporciona la dirección, la contraseña será este argumento - `-b, --address-as-bookmark` resuelve el argumento de la dirección como un nombre de marcador diff --git a/docs/fr/man.md b/docs/fr/man.md index 562a543..7914f82 100644 --- a/docs/fr/man.md +++ b/docs/fr/man.md @@ -37,11 +37,11 @@ termscp peut être démarré avec les options suivantes : -`termscp [options]... [protocol://user@address:port:wrkdir] [local-wrkdir]` +`termscp [options]... [protocol://user@address:port:wrkdir] [protocol://user@address:port:wrkdir] [local-wrkdir]` ou -`termscp [options]... -b [bookmark-name] [local-wrkdir]` +`termscp [options]... -b [bookmark-name] -b [bookmark-name] [local-wrkdir]` - `-P, --password ` si l'adresse est fournie, le mot de passe sera cet argument - `-b, --address-as-bookmark` résoudre l'argument d'adresse en tant que nom de signet diff --git a/docs/it/man.md b/docs/it/man.md index fe4e663..d4379b8 100644 --- a/docs/it/man.md +++ b/docs/it/man.md @@ -37,11 +37,11 @@ termscp può essere lanciato con questi argomenti: -`termscp [options]... [protocol://user@address:port:wrkdir] [local-wrkdir]` +`termscp [options]... [protocol://user@address:port:wrkdir] [protocol://user@address:port:wrkdir] [local-wrkdir]` O -`termscp [options]... -b [bookmark-name] [local-wrkdir]` +`termscp [options]... -b [bookmark-name] -b [bookmark-name] [local-wrkdir]` - `-P, --password ` Se viene fornito l'argomento indirizzo, questa sarà la password utilizzata per autenticarsi - `-b, --address-as-bookmark` risolve l'argomento indirizzo come nome di un segnalibro diff --git a/docs/man.md b/docs/man.md index b37525a..2e418cf 100644 --- a/docs/man.md +++ b/docs/man.md @@ -40,13 +40,15 @@ termscp can be started with the following options: -`termscp [options]... [protocol://user@address:port:wrkdir] [local-wrkdir]` +`termscp [options]... [protocol://user@address:port:wrkdir] [protocol://user@address:port:wrkdir] [local-wrkdir]` OR -`termscp [options]... -b [bookmark-name] [local-wrkdir]` +`termscp [options]... -b [bookmark-name] -b [bookmark-name] [local-wrkdir]` -- `-P, --password ` if address is provided, password will be this argument +AND any combination of the two + +- `-P, --password ` if address is provided, password will be this argument. A password *can* be specified for each remote provided. The order must be the same of the address argument. The use of this parameter is discouraged. - `-b, --address-as-bookmark` resolve address argument as a bookmark name - `-q, --quiet` Disable logging - `-v, --version` Print version info diff --git a/docs/ptbr/man.md b/docs/ptbr/man.md index 64da190..2b7131c 100644 --- a/docs/ptbr/man.md +++ b/docs/ptbr/man.md @@ -40,11 +40,11 @@ O termscp pode ser iniciado com as seguintes opções: -`termscp [opções]... [protocol://usuário@endereço:porta:diretório-trabalho] [diretório-trabalho-local]` +`termscp [opções]... [protocol://usuário@endereço:porta:diretório-trabalho] [protocol://usuário@endereço:porta:diretório-trabalho] [diretório-trabalho-local]` OU -`termscp [opções]... -b [nome-do-favorito] [diretório-trabalho-local]` +`termscp [opções]... -b [nome-do-favorito] -b [nome-do-favorito] [diretório-trabalho-local]` - `-P, --password ` se o endereço for fornecido, a senha será este argumento - `-b, --address-as-bookmark` resolve o argumento do endereço como um nome de favorito diff --git a/docs/zh-CN/man.md b/docs/zh-CN/man.md index 9512e3e..29cb5e5 100644 --- a/docs/zh-CN/man.md +++ b/docs/zh-CN/man.md @@ -37,11 +37,11 @@ termscp启动时可以使用以下选项: -`termscp [options]... [protocol://user@address:port:wrkdir] [local-wrkdir]` +`termscp [options]... [protocol://user@address:port:wrkdir] [protocol://user@address:port:wrkdir] [local-wrkdir]` 或作为 -`termscp [options]... -b [bookmark-name] [local-wrkdir]` +`termscp [options]... -b [bookmark-name] -b [bookmark-name] [local-wrkdir]` - `-P, --password ` 登陆密码 - `-b, --address-as-bookmark` 将地址参数解析为书签名称 diff --git a/src/activity_manager.rs b/src/activity_manager.rs index 4be4eff..89d9e5b 100644 --- a/src/activity_manager.rs +++ b/src/activity_manager.rs @@ -2,14 +2,16 @@ //! //! `activity_manager` is the module which provides run methods and handling for activities -// Deps -// Namespaces +use std::env; use std::path::PathBuf; use std::time::Duration; use remotefs_ssh::SshKeyStorage as SshKeyStorageTrait; -use crate::filetransfer::{FileTransferParams, FileTransferProtocol, HostBridgeParams}; +use crate::cli::{Remote, RemoteArgs}; +use crate::filetransfer::{ + FileTransferParams, FileTransferProtocol, HostBridgeParams, ProtocolParams, +}; use crate::host::HostError; use crate::system::bookmarks_client::BookmarksClient; use crate::system::config_client::ConfigClient; @@ -30,6 +32,16 @@ pub enum NextActivity { SetupActivity, } +pub enum Host { + HostBridge, + Remote, +} + +pub enum HostParams { + HostBridge(HostBridgeParams), + Remote(FileTransferParams), +} + /// The activity manager takes care of running activities and handling them until the application has ended pub struct ActivityManager { context: Option, @@ -62,10 +74,100 @@ impl ActivityManager { }) } + /// Configure remote args + pub fn configure_remote_args(&mut self, remote_args: RemoteArgs) -> Result<(), String> { + // Set for host bridge + match remote_args.host_bridge { + Remote::Bookmark(params) => self.resolve_bookmark_name( + Host::HostBridge, + ¶ms.name, + params.password.as_deref(), + ), + Remote::Host(host_params) => self.set_host_params( + HostParams::HostBridge(HostBridgeParams::Remote( + host_params.file_transfer_params.protocol, + host_params.file_transfer_params.params, + )), + host_params.password.as_deref(), + ), + Remote::None => self.set_host_params( + HostParams::HostBridge(HostBridgeParams::Localhost( + env::current_dir() + .map_err(|e| format!("Could not get current directory: {e}"))?, + )), + None, + ), + }?; + + // set remote + match remote_args.remote { + Remote::Bookmark(params) => { + self.resolve_bookmark_name(Host::Remote, ¶ms.name, params.password.as_deref()) + } + Remote::Host(host_params) => self.set_host_params( + HostParams::Remote(host_params.file_transfer_params), + host_params.password.as_deref(), + ), + Remote::None => Ok(()), + } + } + /// Set file transfer params - pub fn set_filetransfer_params( + pub fn set_host_params( &mut self, - mut params: FileTransferParams, + host: HostParams, + password: Option<&str>, + ) -> Result<(), String> { + let (remote_local_path, remote_remote_path) = match &host { + HostParams::Remote(params) => (params.local_path.clone(), params.remote_path.clone()), + _ => (None, None), + }; + + let mut remote_params = match &host { + HostParams::HostBridge(HostBridgeParams::Remote(protocol, protocol_params)) => { + Some((*protocol, protocol_params.clone())) + } + HostParams::HostBridge(HostBridgeParams::Localhost(_)) => None, + HostParams::Remote(ft_params) => Some((ft_params.protocol, ft_params.params.clone())), + }; + + // Put params into the context + if let Some((protocol, params)) = remote_params.as_mut() { + self.resolve_password_for_protocol_params(*protocol, params, password)?; + } + + match host { + HostParams::HostBridge(HostBridgeParams::Localhost(path)) => { + self.context + .as_mut() + .unwrap() + .set_host_bridge_params(HostBridgeParams::Localhost(path)); + } + HostParams::HostBridge(HostBridgeParams::Remote(_, _)) => { + let (protocol, params) = remote_params.unwrap(); + self.context + .as_mut() + .unwrap() + .set_host_bridge_params(HostBridgeParams::Remote(protocol, params)); + } + HostParams::Remote(_) => { + let (protocol, params) = remote_params.unwrap(); + let params = FileTransferParams { + local_path: remote_local_path, + remote_path: remote_remote_path, + protocol, + params, + }; + self.context.as_mut().unwrap().set_remote_params(params); + } + } + Ok(()) + } + + fn resolve_password_for_protocol_params( + &mut self, + protocol: FileTransferProtocol, + params: &mut ProtocolParams, password: Option<&str>, ) -> Result<(), String> { // Set password if provided @@ -73,13 +175,13 @@ impl ActivityManager { if let Some(password) = password { params.set_default_secret(password.to_string()); } else if matches!( - params.protocol, + protocol, FileTransferProtocol::Scp | FileTransferProtocol::Sftp, - ) && params.params.generic_params().is_some() + ) && params.generic_params().is_some() { // * if protocol is SCP or SFTP check whether a SSH key is registered for this remote, in case not ask password let storage = SshKeyStorage::from(self.context.as_ref().unwrap().config()); - let generic_params = params.params.generic_params().unwrap(); + let generic_params = params.generic_params().unwrap(); if storage .resolve( &generic_params.address, @@ -94,7 +196,7 @@ impl ActivityManager { "storage could not find any suitable key for {}... prompting for password", generic_params.address ); - self.prompt_password(&mut params)?; + self.prompt_password(params)?; } else { debug!( "a key is already set for {}; password is not required", @@ -102,19 +204,19 @@ impl ActivityManager { ); } } else { - self.prompt_password(&mut params)?; + self.prompt_password(params)?; } } - // Put params into the context - self.context.as_mut().unwrap().set_ftparams(params); + Ok(()) } /// Prompt user for password to set into params. - fn prompt_password(&mut self, params: &mut FileTransferParams) -> Result<(), String> { + fn prompt_password(&mut self, params: &mut ProtocolParams) -> Result<(), String> { let ctx = self.context.as_mut().unwrap(); + let prompt = format!("Password for {}: ", params.host_name()); - match tty::read_secret_from_tty(ctx.terminal(), "Password: ") { + match tty::read_secret_from_tty(ctx.terminal(), prompt) { Err(err) => Err(format!("Could not read password: {err}")), Ok(Some(secret)) => { debug!( @@ -132,16 +234,28 @@ impl ActivityManager { /// Returns error if bookmark is not found pub fn resolve_bookmark_name( &mut self, + host: Host, bookmark_name: &str, password: Option<&str>, ) -> Result<(), String> { if let Some(bookmarks_client) = self.context.as_mut().unwrap().bookmarks_client_mut() { - match bookmarks_client.get_bookmark(bookmark_name) { - None => Err(format!( - r#"Could not resolve bookmark name: "{bookmark_name}" no such bookmark"# - )), - Some(params) => self.set_filetransfer_params(params, password), - } + let params = match bookmarks_client.get_bookmark(bookmark_name) { + None => { + return Err(format!( + r#"Could not resolve bookmark name: "{bookmark_name}" no such bookmark"# + )) + } + Some(params) => params, + }; + + let params = match host { + Host::Remote => HostParams::Remote(params), + Host::HostBridge => { + HostParams::HostBridge(HostBridgeParams::Remote(params.protocol, params.params)) + } + }; + + self.set_host_params(params, password) } else { Err(String::from( "Could not resolve bookmark name: bookmarks client not initialized", @@ -235,8 +349,17 @@ impl ActivityManager { return None; } }; + + let host_bridge_params = match ctx.host_bridge_params() { + Some(params) => params.clone(), + None => { + error!("Failed to start FileTransferActivity: host bridge params is None"); + return None; + } + }; + // If ft params is None, return None - let remote_params: &FileTransferParams = match ctx.ft_params() { + let remote_params: &FileTransferParams = match ctx.remote_params() { Some(ft_params) => ft_params, None => { error!("Failed to start FileTransferActivity: file transfer params is None"); @@ -244,19 +367,6 @@ impl ActivityManager { } }; - // get local path: - // - if set in file transfer params, get it from there - // - otherwise is env current dir - // - otherwise is / - let local_wrkdir = remote_params - .local_path - .clone() - .or(std::env::current_dir().ok()) - .unwrap_or(PathBuf::from("/")); - - // TODO: get host params from prev activity - let host_bridge_params = HostBridgeParams::Localhost(local_wrkdir); - let mut activity: FileTransferActivity = FileTransferActivity::new(host_bridge_params, remote_params, self.ticks); // Prepare result diff --git a/src/cli_opts.rs b/src/cli.rs similarity index 71% rename from src/cli_opts.rs rename to src/cli.rs index 6fc08ce..66b9d30 100644 --- a/src/cli_opts.rs +++ b/src/cli.rs @@ -2,13 +2,15 @@ //! //! defines the types for main.rs types +mod remote; + use std::path::PathBuf; use std::time::Duration; use argh::FromArgs; +pub use remote::{Remote, RemoteArgs}; use crate::activity_manager::NextActivity; -use crate::filetransfer::FileTransferParams; use crate::system::logging::LogLevel; pub enum Task { @@ -17,7 +19,7 @@ pub enum Task { InstallUpdate, } -#[derive(FromArgs)] +#[derive(Default, FromArgs)] #[argh(description = " where positional can be: - [address_a] [address_b] [local-wrkdir] @@ -39,14 +41,15 @@ pub struct Args { #[argh(subcommand)] pub nested: Option, /// resolve address argument as a bookmark name - #[argh(switch, short = 'b')] - pub address_as_bookmark: bool, + #[argh(option, short = 'b')] + pub bookmark: Vec, /// enable TRACE log level #[argh(switch, short = 'D')] pub debug: bool, - /// provide password from CLI + /// provide password from CLI; if you need to provide multiple passwords, use multiple -P flags. + /// In case just respect the order of the addresses #[argh(option, short = 'P')] - pub password: Option, + pub password: Vec, /// disable logging #[argh(switch, short = 'q')] pub quiet: bool, @@ -57,10 +60,7 @@ pub struct Args { #[argh(switch, short = 'v')] pub version: bool, // -- positional - #[argh( - positional, - description = "protocol://user@address:port:wrkdir local-wrkdir" - )] + #[argh(positional, description = "address1 address2 local-wrkdir")] pub positional: Vec, } @@ -92,7 +92,7 @@ pub struct LoadThemeArgs { } pub struct RunOpts { - pub remote: Remote, + pub remote: RemoteArgs, pub ticks: Duration, pub log_level: LogLevel, pub task: Task, @@ -124,45 +124,10 @@ impl RunOpts { impl Default for RunOpts { fn default() -> Self { Self { - remote: Remote::None, + remote: RemoteArgs::default(), ticks: Duration::from_millis(10), log_level: LogLevel::Info, task: Task::Activity(NextActivity::Authentication), } } } - -#[allow(clippy::large_enum_variant)] -pub enum Remote { - Bookmark(BookmarkParams), - Host(HostParams), - None, -} - -pub struct BookmarkParams { - pub name: String, - pub password: Option, -} - -pub struct HostParams { - pub params: FileTransferParams, - pub password: Option, -} - -impl BookmarkParams { - pub fn new>(name: S, password: Option) -> Self { - Self { - name: name.as_ref().to_string(), - password: password.map(|x| x.as_ref().to_string()), - } - } -} - -impl HostParams { - pub fn new>(params: FileTransferParams, password: Option) -> Self { - Self { - params, - password: password.map(|x| x.as_ref().to_string()), - } - } -} diff --git a/src/cli/remote.rs b/src/cli/remote.rs new file mode 100644 index 0000000..c6dccfc --- /dev/null +++ b/src/cli/remote.rs @@ -0,0 +1,262 @@ +use std::path::{Path, PathBuf}; + +use super::Args; +use crate::filetransfer::FileTransferParams; +use crate::utils; + +/// Address type +enum AddrType { + Address, + Bookmark, +} + +/// Args for remote connection +#[derive(Debug)] +pub struct RemoteArgs { + pub host_bridge: Remote, + pub remote: Remote, + pub local_dir: Option, +} + +impl Default for RemoteArgs { + fn default() -> Self { + Self { + host_bridge: Remote::None, + remote: Remote::None, + local_dir: None, + } + } +} + +impl TryFrom<&Args> for RemoteArgs { + type Error = String; + + fn try_from(args: &Args) -> Result { + let mut remote_args = RemoteArgs::default(); + // validate arguments + match (args.bookmark.len(), args.positional.len()) { + (0, positional) if positional < 4 => Ok(()), + (1, positional) if positional < 3 => Ok(()), + (2, positional) if positional < 2 => Ok(()), + (_, _) => Err("Too many arguments".to_string()), + }?; + // parse bookmark first + let last_item_index = (args.bookmark.len() + args.positional.len()) + .checked_sub(1) + .unwrap_or_default(); + + let mut hosts = vec![]; + + for (i, (addr_type, arg)) in args + .bookmark + .iter() + .map(|x| (AddrType::Bookmark, x)) + .chain(args.positional.iter().map(|x| (AddrType::Address, x))) + .enumerate() + { + // check if has password + let password = args.password.get(i).cloned(); + + // check if is last item and so a possible local dir + if i == last_item_index && Path::new(arg).exists() { + remote_args.local_dir = Some(PathBuf::from(arg)); + continue; + } + + let remote = match addr_type { + AddrType::Address => Self::parse_remote_address(arg) + .map(|x| Remote::Host(HostParams::new(x, password)))?, + AddrType::Bookmark => Remote::Bookmark(BookmarkParams::new(arg, password.as_ref())), + }; + + // set remote + hosts.push(remote); + } + + // set args based on hosts len + if hosts.len() == 1 { + remote_args.remote = hosts.pop().unwrap(); + } else if hosts.len() == 2 { + remote_args.host_bridge = hosts.pop().unwrap(); + remote_args.remote = hosts.pop().unwrap(); + } + + Ok(remote_args) + } +} + +impl RemoteArgs { + /// Parse remote address + fn parse_remote_address(remote: &str) -> Result { + utils::parser::parse_remote_opt(remote).map_err(|e| format!("Bad address option: {e}")) + } +} + +/// Remote argument type +#[allow(clippy::large_enum_variant)] +#[derive(Debug)] +pub enum Remote { + /// Bookmark name argument + Bookmark(BookmarkParams), + /// Host argument + Host(HostParams), + /// Unspecified + None, +} + +/// Bookmark parameters +#[derive(Debug)] +pub struct BookmarkParams { + /// bookmark name + pub name: String, + /// bookmark password + pub password: Option, +} + +/// Host parameters +#[derive(Debug)] +pub struct HostParams { + /// file transfer parameters + pub file_transfer_params: FileTransferParams, + /// host password specified in arguments + pub password: Option, +} + +impl BookmarkParams { + pub fn new>(name: S, password: Option) -> Self { + Self { + name: name.as_ref().to_string(), + password: password.map(|x| x.as_ref().to_string()), + } + } +} + +impl HostParams { + pub fn new>(params: FileTransferParams, password: Option) -> Self { + Self { + file_transfer_params: params, + password: password.map(|x| x.as_ref().to_string()), + } + } +} + +#[cfg(test)] +mod test { + + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_should_make_remote_args_from_args_one_remote() { + let args = Args { + positional: vec!["scp://host1".to_string()], + ..Default::default() + }; + + let remote_args = RemoteArgs::try_from(&args).unwrap(); + assert!(matches!(remote_args.host_bridge, Remote::None)); + assert!(matches!(remote_args.remote, Remote::Host(_))); + assert_eq!(remote_args.local_dir, None); + } + + #[test] + fn test_should_make_remote_args_from_args_two_remotes() { + let args = Args { + positional: vec!["scp://host1".to_string(), "scp://host2".to_string()], + ..Default::default() + }; + + let remote_args = RemoteArgs::try_from(&args).unwrap(); + assert!(matches!(remote_args.host_bridge, Remote::Host(_))); + assert!(matches!(remote_args.remote, Remote::Host(_))); + assert_eq!(remote_args.local_dir, None); + } + + #[test] + fn test_should_make_remote_args_from_two_remotes_and_local_dir() { + let args = Args { + positional: vec![ + "scp://host1".to_string(), + "scp://host2".to_string(), + "/home".to_string(), + ], + ..Default::default() + }; + + let remote_args = RemoteArgs::try_from(&args).unwrap(); + assert!(matches!(remote_args.host_bridge, Remote::Host(_))); + assert!(matches!(remote_args.remote, Remote::Host(_))); + assert_eq!(remote_args.local_dir, Some(PathBuf::from("/home"))); + } + + #[test] + fn test_should_make_remote_args_from_args_one_bookmarks() { + let args = Args { + bookmark: vec!["foo".to_string()], + ..Default::default() + }; + + let remote_args = RemoteArgs::try_from(&args).unwrap(); + assert!(matches!(remote_args.host_bridge, Remote::None)); + assert!(matches!(remote_args.remote, Remote::Bookmark(_))); + assert_eq!(remote_args.local_dir, None); + } + + #[test] + fn test_should_make_remote_args_from_args_two_bookmarks() { + let args = Args { + bookmark: vec!["foo".to_string(), "bar".to_string()], + ..Default::default() + }; + + let remote_args = RemoteArgs::try_from(&args).unwrap(); + assert!(matches!(remote_args.host_bridge, Remote::Bookmark(_))); + assert!(matches!(remote_args.remote, Remote::Bookmark(_))); + assert_eq!(remote_args.local_dir, None); + } + + #[test] + fn test_should_make_remote_args_from_two_bookmarks_and_local_dir() { + let args = Args { + bookmark: vec!["foo".to_string(), "bar".to_string()], + positional: vec!["/home".to_string()], + ..Default::default() + }; + + let remote_args = RemoteArgs::try_from(&args).unwrap(); + assert!(matches!(remote_args.host_bridge, Remote::Bookmark(_))); + assert!(matches!(remote_args.remote, Remote::Bookmark(_))); + assert_eq!(remote_args.local_dir, Some(PathBuf::from("/home"))); + } + + #[test] + fn test_should_make_remote_args_from_one_bookmark_and_one_remote() { + let args = Args { + bookmark: vec!["foo".to_string()], + positional: vec!["scp://host1".to_string()], + ..Default::default() + }; + + let remote_args = RemoteArgs::try_from(&args).unwrap(); + + assert!(matches!(remote_args.host_bridge, Remote::Host(_))); + assert!(matches!(remote_args.remote, Remote::Bookmark(_))); + assert_eq!(remote_args.local_dir, None); + } + + #[test] + fn test_should_make_remote_args_from_one_bookmark_and_one_remote_with_local_dir() { + let args = Args { + positional: vec!["scp://host1".to_string(), "/home".to_string()], + bookmark: vec!["foo".to_string()], + ..Default::default() + }; + + let remote_args = RemoteArgs::try_from(&args).unwrap(); + + assert!(matches!(remote_args.host_bridge, Remote::Host(_))); + assert!(matches!(remote_args.remote, Remote::Bookmark(_))); + assert_eq!(remote_args.local_dir, Some(PathBuf::from("/home"))); + } +} diff --git a/src/filetransfer/params.rs b/src/filetransfer/params.rs index 450a57f..b98ffdf 100644 --- a/src/filetransfer/params.rs +++ b/src/filetransfer/params.rs @@ -24,6 +24,15 @@ pub enum HostBridgeParams { Remote(FileTransferProtocol, ProtocolParams), } +impl HostBridgeParams { + pub fn unwrap_protocol_params(&self) -> &ProtocolParams { + match self { + HostBridgeParams::Localhost(_) => panic!("Localhost has no protocol params"), + HostBridgeParams::Remote(_, params) => params, + } + } +} + /// Holds connection parameters for file transfers #[derive(Debug, Clone)] pub struct FileTransferParams { @@ -43,6 +52,43 @@ pub enum ProtocolParams { WebDAV(WebDAVProtocolParams), } +impl ProtocolParams { + pub fn password_missing(&self) -> bool { + match self { + ProtocolParams::AwsS3(params) => params.password_missing(), + ProtocolParams::Generic(params) => params.password_missing(), + ProtocolParams::Kube(params) => params.password_missing(), + ProtocolParams::Smb(params) => params.password_missing(), + ProtocolParams::WebDAV(params) => params.password_missing(), + } + } + + /// Set the secret to ft params for the default secret field for this protocol + pub fn set_default_secret(&mut self, secret: String) { + match self { + ProtocolParams::AwsS3(params) => params.set_default_secret(secret), + ProtocolParams::Generic(params) => params.set_default_secret(secret), + ProtocolParams::Kube(params) => params.set_default_secret(secret), + ProtocolParams::Smb(params) => params.set_default_secret(secret), + ProtocolParams::WebDAV(params) => params.set_default_secret(secret), + } + } + + pub fn host_name(&self) -> String { + match self { + ProtocolParams::AwsS3(params) => params.bucket_name.clone(), + ProtocolParams::Generic(params) => params.address.clone(), + ProtocolParams::Kube(params) => params + .namespace + .as_ref() + .cloned() + .unwrap_or_else(|| String::from("default")), + ProtocolParams::Smb(params) => params.address.clone(), + ProtocolParams::WebDAV(params) => params.uri.clone(), + } + } +} + /// Protocol params used by most common protocols #[derive(Debug, Clone)] pub struct GenericProtocolParams { @@ -77,25 +123,15 @@ impl FileTransferParams { /// Returns whether a password is supposed to be required for this protocol params. /// The result true is returned ONLY if the supposed secret is MISSING!!! + #[cfg(test)] pub fn password_missing(&self) -> bool { - match &self.params { - ProtocolParams::AwsS3(params) => params.password_missing(), - ProtocolParams::Generic(params) => params.password_missing(), - ProtocolParams::Kube(params) => params.password_missing(), - ProtocolParams::Smb(params) => params.password_missing(), - ProtocolParams::WebDAV(params) => params.password_missing(), - } + self.params.password_missing() } /// Set the secret to ft params for the default secret field for this protocol + #[cfg(test)] pub fn set_default_secret(&mut self, secret: String) { - match &mut self.params { - ProtocolParams::AwsS3(params) => params.set_default_secret(secret), - ProtocolParams::Generic(params) => params.set_default_secret(secret), - ProtocolParams::Kube(params) => params.set_default_secret(secret), - ProtocolParams::Smb(params) => params.set_default_secret(secret), - ProtocolParams::WebDAV(params) => params.set_default_secret(secret), - } + self.params.set_default_secret(secret); } } diff --git a/src/host/bridge.rs b/src/host/bridge.rs index a9aba17..e488121 100644 --- a/src/host/bridge.rs +++ b/src/host/bridge.rs @@ -12,6 +12,18 @@ use super::HostResult; /// implement a real bridge when the resource is first loaded on the local /// filesystem and then processed on the remote. pub trait HostBridge { + /// Connect to host + fn connect(&mut self) -> HostResult<()>; + + /// Disconnect from host + fn disconnect(&mut self) -> HostResult<()>; + + /// Returns whether the host is connected + fn is_connected(&mut self) -> bool; + + /// Returns whether the host is localhost + fn is_localhost(&self) -> bool; + /// Print working directory fn pwd(&mut self) -> HostResult; diff --git a/src/host/localhost.rs b/src/host/localhost.rs index 70ff2b2..259ff06 100644 --- a/src/host/localhost.rs +++ b/src/host/localhost.rs @@ -62,6 +62,22 @@ impl Localhost { } impl HostBridge for Localhost { + fn connect(&mut self) -> HostResult<()> { + Ok(()) + } + + fn disconnect(&mut self) -> HostResult<()> { + Ok(()) + } + + fn is_connected(&mut self) -> bool { + true + } + + fn is_localhost(&self) -> bool { + true + } + fn pwd(&mut self) -> HostResult { Ok(self.wrkdir.clone()) } diff --git a/src/host/remote_bridged.rs b/src/host/remote_bridged.rs index 7632bef..497d9bc 100644 --- a/src/host/remote_bridged.rs +++ b/src/host/remote_bridged.rs @@ -49,8 +49,25 @@ impl From> for RemoteBridged { } impl HostBridge for RemoteBridged { + fn connect(&mut self) -> HostResult<()> { + self.remote.connect().map(|_| ()).map_err(HostError::from) + } + + fn disconnect(&mut self) -> HostResult<()> { + self.remote.disconnect().map_err(HostError::from) + } + + fn is_connected(&mut self) -> bool { + self.remote.is_connected() + } + + fn is_localhost(&self) -> bool { + false + } + fn pwd(&mut self) -> HostResult { - todo!() + debug!("Getting working directory"); + self.remote.pwd().map_err(HostError::from) } fn change_wrkdir(&mut self, new_dir: &Path) -> HostResult { diff --git a/src/main.rs b/src/main.rs index 9e1e839..631cafd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,13 @@ -const TERMSCP_VERSION: &str = env!("CARGO_PKG_VERSION"); -const TERMSCP_AUTHORS: &str = env!("CARGO_PKG_AUTHORS"); +mod activity_manager; +mod cli; +mod config; +mod explorer; +mod filetransfer; +mod host; +mod support; +mod system; +mod ui; +mod utils; // Crates #[macro_use] @@ -13,28 +21,18 @@ extern crate log; #[macro_use] extern crate magic_crypt; -// External libs use std::env; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::time::Duration; -// Include -mod activity_manager; -mod cli_opts; -mod config; -mod explorer; -mod filetransfer; -mod host; -mod support; -mod system; -mod ui; -mod utils; +use self::activity_manager::{ActivityManager, NextActivity}; +use self::cli::{Args, ArgsSubcommands, RemoteArgs, RunOpts, Task}; +use self::system::logging::{self, LogLevel}; -// namespaces -use activity_manager::{ActivityManager, NextActivity}; -use cli_opts::{Args, ArgsSubcommands, BookmarkParams, HostParams, Remote, RunOpts, Task}; -use filetransfer::FileTransferParams; -use system::logging::{self, LogLevel}; +const EXIT_CODE_SUCCESS: i32 = 0; +const EXIT_CODE_ERROR: i32 = 1; +const TERMSCP_VERSION: &str = env!("CARGO_PKG_VERSION"); +const TERMSCP_AUTHORS: &str = env!("CARGO_PKG_AUTHORS"); fn main() { let args: Args = argh::from_env(); @@ -84,9 +82,8 @@ fn parse_args(args: Args) -> Result { // Match ticks run_opts.ticks = Duration::from_millis(args.ticks); // Remote argument - match parse_address_arg(&args) { + match RemoteArgs::try_from(&args) { Err(err) => return Err(err), - Ok(Remote::None) => {} Ok(remote) => { // Set params run_opts.remote = remote; @@ -96,10 +93,8 @@ fn parse_args(args: Args) -> Result { } // Local directory - if let Some(localdir) = args.positional.get(1) { - // Change working directory if local dir is set - let localdir: PathBuf = PathBuf::from(localdir); - if let Err(err) = env::set_current_dir(localdir.as_path()) { + if let Some(localdir) = run_opts.remote.local_dir.as_deref() { + if let Err(err) = env::set_current_dir(localdir) { return Err(format!("Bad working directory argument: {err}")); } } @@ -111,29 +106,6 @@ fn parse_args(args: Args) -> Result { Ok(run_opts) } -/// Parse address argument from cli args -fn parse_address_arg(args: &Args) -> Result { - if let Some(remote) = args.positional.first() { - if args.address_as_bookmark { - Ok(Remote::Bookmark(BookmarkParams::new( - remote, - args.password.as_ref(), - ))) - } else { - // Parse address - parse_remote_address(remote.as_str()) - .map(|x| Remote::Host(HostParams::new(x, args.password.as_deref()))) - } - } else { - Ok(Remote::None) - } -} - -/// Parse remote address -fn parse_remote_address(remote: &str) -> Result { - utils::parser::parse_remote_opt(remote).map_err(|e| format!("Bad address option: {e}")) -} - /// Run task and return rc fn run(run_opts: RunOpts) -> i32 { match run_opts.task { @@ -147,11 +119,11 @@ fn run_import_theme(theme: &Path) -> i32 { match support::import_theme(theme) { Ok(_) => { println!("Theme has been successfully imported!"); - 0 + EXIT_CODE_ERROR } Err(err) => { eprintln!("{err}"); - 1 + EXIT_CODE_ERROR } } } @@ -160,41 +132,32 @@ fn run_install_update() -> i32 { match support::install_update() { Ok(msg) => { println!("{msg}"); - 0 + EXIT_CODE_SUCCESS } Err(err) => { eprintln!("Could not install update: {err}"); - 1 + EXIT_CODE_ERROR } } } -fn run_activity(activity: NextActivity, ticks: Duration, remote: Remote) -> i32 { +fn run_activity(activity: NextActivity, ticks: Duration, remote_args: RemoteArgs) -> i32 { // Create activity manager (and context too) let mut manager: ActivityManager = match ActivityManager::new(ticks) { Ok(m) => m, Err(err) => { eprintln!("Could not start activity manager: {err}"); - return 1; + return EXIT_CODE_ERROR; } }; + // Set file transfer params if set - match remote { - Remote::Bookmark(BookmarkParams { name, password }) => { - if let Err(err) = manager.resolve_bookmark_name(&name, password.as_deref()) { - eprintln!("{err}"); - return 1; - } - } - Remote::Host(HostParams { params, password }) => { - if let Err(err) = manager.set_filetransfer_params(params, password.as_deref()) { - eprintln!("{err}"); - return 1; - } - } - Remote::None => {} + if let Err(err) = manager.configure_remote_args(remote_args) { + eprintln!("{err}"); + return EXIT_CODE_ERROR; } + manager.run(activity); - 0 + EXIT_CODE_SUCCESS } diff --git a/src/ui/activities/auth/mod.rs b/src/ui/activities/auth/mod.rs index cebf5ec..ff6c839 100644 --- a/src/ui/activities/auth/mod.rs +++ b/src/ui/activities/auth/mod.rs @@ -275,7 +275,7 @@ impl Activity for AuthActivity { fn on_create(&mut self, mut context: Context) { debug!("Initializing activity"); // Initialize file transfer params - context.set_ftparams(FileTransferParams::default()); + context.set_remote_params(FileTransferParams::default()); // Set context self.context = Some(context); // Clear terminal diff --git a/src/ui/activities/auth/update.rs b/src/ui/activities/auth/update.rs index 79adde7..f6f00cf 100644 --- a/src/ui/activities/auth/update.rs +++ b/src/ui/activities/auth/update.rs @@ -29,7 +29,7 @@ impl AuthActivity { Ok(params) => { self.save_recent(); // Set file transfer params to context - self.context_mut().set_ftparams(params); + self.context_mut().set_remote_params(params); // Set exit reason self.exit_reason = Some(super::ExitReason::Connect); } diff --git a/src/ui/activities/filetransfer/misc.rs b/src/ui/activities/filetransfer/misc.rs index aef59c0..70ffb32 100644 --- a/src/ui/activities/filetransfer/misc.rs +++ b/src/ui/activities/filetransfer/misc.rs @@ -9,7 +9,7 @@ use tuirealm::{PollStrategy, Update}; use super::browser::FileExplorerTab; use super::{ConfigClient, FileTransferActivity, Id, LogLevel, LogRecord, TransferPayload}; -use crate::filetransfer::ProtocolParams; +use crate::filetransfer::{HostBridgeParams, ProtocolParams}; use crate::system::environment; use crate::system::notifications::Notification; use crate::utils::fmt::{fmt_millis, fmt_path_elide_ex}; @@ -105,8 +105,28 @@ impl FileTransferActivity { /// Get remote hostname pub(super) fn get_remote_hostname(&self) -> String { - let ft_params = self.context().ft_params().unwrap(); - match &ft_params.params { + let ft_params = self.context().remote_params().unwrap(); + self.get_hostname(&ft_params.params) + } + + pub(super) fn get_hostbridge_hostname(&self) -> String { + let host_bridge_params = self.context().host_bridge_params().unwrap(); + match host_bridge_params { + HostBridgeParams::Localhost(_) => { + let hostname = match hostname::get() { + Ok(h) => h, + Err(_) => return String::from("localhost"), + }; + let hostname: String = hostname.as_os_str().to_string_lossy().to_string(); + let tokens: Vec<&str> = hostname.split('.').collect(); + String::from(*tokens.first().unwrap_or(&"localhost")) + } + HostBridgeParams::Remote(_, params) => self.get_hostname(params), + } + } + + fn get_hostname(&self, params: &ProtocolParams) -> String { + match params { ProtocolParams::Generic(params) => params.address.clone(), ProtocolParams::AwsS3(params) => params.bucket_name.clone(), ProtocolParams::Kube(params) => { @@ -226,17 +246,10 @@ impl FileTransferActivity { .size() .map(|x| (x.width / 2) - 2) .unwrap_or(0) as usize; - let hostname: String = match hostname::get() { - Ok(h) => { - let hostname: String = h.as_os_str().to_string_lossy().to_string(); - let tokens: Vec<&str> = hostname.split('.').collect(); - String::from(*tokens.first().unwrap_or(&"localhost")) - } - Err(_) => String::from("localhost"), - }; + let hostname = self.get_hostbridge_hostname(); + let hostname: String = format!( - "{}:{} ", - hostname, + "{hostname}:{} ", fmt_path_elide_ex( self.host_bridge().wrkdir.as_path(), width, diff --git a/src/ui/activities/filetransfer/mod.rs b/src/ui/activities/filetransfer/mod.rs index 85931f3..4fd7e1f 100644 --- a/src/ui/activities/filetransfer/mod.rs +++ b/src/ui/activities/filetransfer/mod.rs @@ -231,8 +231,10 @@ pub struct FileTransferActivity { cache: Option, /// Fs watcher fswatcher: Option, - /// connected once - connected: bool, + /// host bridge connected + host_bridge_connected: bool, + /// remote connected once + remote_connected: bool, } impl FileTransferActivity { @@ -244,6 +246,9 @@ impl FileTransferActivity { ) -> Self { // Get config client let config_client: ConfigClient = Self::init_config_client(); + // init host bridge + let host_bridge = HostBridgeBuilder::build(host_bridge_params, &config_client); + let host_bridge_connected = host_bridge.is_localhost(); Self { exit_reason: None, context: None, @@ -253,7 +258,7 @@ impl FileTransferActivity { .default_input_listener(ticks), ), redraw: true, - host_bridge: HostBridgeBuilder::build(host_bridge_params, &config_client), + host_bridge, client: RemoteFsBuilder::build( remote_params.protocol, remote_params.params.clone(), @@ -274,7 +279,8 @@ impl FileTransferActivity { None } }, - connected: false, + host_bridge_connected, + remote_connected: false, } } @@ -371,7 +377,10 @@ impl Activity for FileTransferActivity { error!("Failed to enter raw mode: {}", err); } // Get files at current pwd - self.reload_host_bridge_dir(); + if self.host_bridge.is_localhost() { + debug!("Reloading host bridge directory"); + self.reload_host_bridge_dir(); + } debug!("Read working directory"); // Configure text editor self.setup_text_editor(); @@ -394,15 +403,34 @@ impl Activity for FileTransferActivity { if self.context.is_none() { return; } - // Check if connected (popup must be None, otherwise would try reconnecting in loop in case of error) - if (!self.client.is_connected() || !self.connected) && !self.app.mounted(&Id::FatalPopup) { - let ftparams = self.context().ft_params().unwrap(); + // Check if connected to host bridge (popup must be None, otherwise would try reconnecting in loop in case of error) + if (!self.host_bridge.is_connected() || !self.host_bridge_connected) + && !self.app.mounted(&Id::FatalPopup) + && !self.host_bridge.is_localhost() + { + let host_bridge_params = self.context().host_bridge_params().unwrap(); + let ft_params = host_bridge_params.unwrap_protocol_params(); + // print params + let msg: String = Self::get_connection_msg(ft_params); + // Set init state to connecting popup + self.mount_blocking_wait(msg.as_str()); + // Connect to remote + self.connect_to_host_bridge(); + // Redraw + self.redraw = true; + } + // Check if connected to remote (popup must be None, otherwise would try reconnecting in loop in case of error) + if (!self.client.is_connected() || !self.remote_connected) + && !self.app.mounted(&Id::FatalPopup) + && self.host_bridge.is_connected() + { + let ftparams = self.context().remote_params().unwrap(); // print params let msg: String = Self::get_connection_msg(&ftparams.params); // Set init state to connecting popup self.mount_blocking_wait(msg.as_str()); // Connect to remote - self.connect(); + self.connect_to_remote(); // Redraw self.redraw = true; } @@ -442,6 +470,10 @@ impl Activity for FileTransferActivity { if self.client.is_connected() { let _ = self.client.disconnect(); } + // disconnect host bridge + if self.host_bridge.is_connected() { + let _ = self.host_bridge.disconnect(); + } self.context.take() } } diff --git a/src/ui/activities/filetransfer/session.rs b/src/ui/activities/filetransfer/session.rs index a8296e6..5fd5c47 100644 --- a/src/ui/activities/filetransfer/session.rs +++ b/src/ui/activities/filetransfer/session.rs @@ -45,15 +45,57 @@ pub(super) enum TransferPayload { } impl FileTransferActivity { + pub(super) fn connect_to_host_bridge(&mut self) { + let ft_params = self.context().remote_params().unwrap().clone(); + let entry_dir: Option = ft_params.local_path; + // Connect to host bridge + match self.host_bridge.connect() { + Ok(()) => { + self.host_bridge_connected = self.host_bridge.is_connected(); + if !self.host_bridge_connected { + return; + } + + // Log welcome + self.log( + LogLevel::Info, + format!( + "Established connection with '{}'", + self.get_hostbridge_hostname() + ), + ); + + // Try to change directory to entry directory + let mut remote_chdir: Option = None; + if let Some(remote_path) = &entry_dir { + remote_chdir = Some(remote_path.clone()); + } + if let Some(remote_path) = remote_chdir { + self.local_changedir(remote_path.as_path(), false); + } + // Set state to explorer + self.umount_wait(); + self.reload_host_bridge_dir(); + // Update file lists + self.update_host_bridge_filelist(); + } + Err(err) => { + // Set popup fatal error + self.umount_wait(); + self.mount_fatal(err.to_string()); + } + } + } + /// Connect to remote - pub(super) fn connect(&mut self) { - let ft_params = self.context().ft_params().unwrap().clone(); + pub(super) fn connect_to_remote(&mut self) { + let ft_params = self.context().remote_params().unwrap().clone(); let entry_dir: Option = ft_params.remote_path; // Connect to remote match self.client.connect() { Ok(Welcome { banner, .. }) => { - self.connected = self.client.is_connected(); - if !self.connected { + self.remote_connected = self.client.is_connected(); + if !self.remote_connected { return; } @@ -119,7 +161,7 @@ impl FileTransferActivity { /// Reload remote directory entries and update browser pub(super) fn reload_remote_dir(&mut self) { - if !self.connected { + if !self.remote_connected { return; } // Get current entries @@ -146,11 +188,21 @@ impl FileTransferActivity { /// Reload host_bridge directory entries and update browser pub(super) fn reload_host_bridge_dir(&mut self) { + if !self.host_bridge_connected { + return; + } + self.mount_blocking_wait("Loading host bridge directory..."); - let Ok(wrkdir) = self.host_bridge.pwd() else { - error!("failed to get host working directory"); - return; + let wrkdir = match self.host_bridge.pwd() { + Ok(wrkdir) => wrkdir, + Err(err) => { + self.log_and_alert( + LogLevel::Error, + format!("Could not scan current host bridge directory: {err}"), + ); + return; + } }; let res = self.host_bridge_scan(wrkdir.as_path()); @@ -1122,6 +1174,33 @@ impl FileTransferActivity { } } + pub(super) fn local_changedir(&mut self, path: &Path, push: bool) { + // Get current directory + let prev_dir: PathBuf = self.host_bridge().wrkdir.clone(); + // Change directory + match self.host_bridge.change_wrkdir(path) { + Ok(_) => { + self.log( + LogLevel::Info, + format!("Changed directory on host bridge: {}", path.display()), + ); + // Update files + self.reload_host_bridge_dir(); + // Push prev_dir to stack + if push { + self.host_bridge_mut().pushd(prev_dir.as_path()) + } + } + Err(err) => { + // Report err + self.log_and_alert( + LogLevel::Error, + format!("Could not change working directory: {err}"), + ); + } + } + } + pub(super) fn remote_changedir(&mut self, path: &Path, push: bool) { // Get current directory let prev_dir: PathBuf = self.remote().wrkdir.clone(); diff --git a/src/ui/context.rs b/src/ui/context.rs index bf5965c..a9eb19c 100644 --- a/src/ui/context.rs +++ b/src/ui/context.rs @@ -6,14 +6,15 @@ use tuirealm::terminal::TerminalBridge; use super::store::Store; -use crate::filetransfer::FileTransferParams; +use crate::filetransfer::{FileTransferParams, HostBridgeParams}; use crate::system::bookmarks_client::BookmarksClient; use crate::system::config_client::ConfigClient; use crate::system::theme_provider::ThemeProvider; /// Context holds data structures shared by the activities pub struct Context { - ft_params: Option, + host_bridge_params: Option, + remote_params: Option, bookmarks_client: Option, config_client: ConfigClient, pub(crate) store: Store, @@ -33,7 +34,8 @@ impl Context { let mut ctx = Context { bookmarks_client, config_client, - ft_params: None, + host_bridge_params: None, + remote_params: None, store: Store::init(), terminal: TerminalBridge::new().expect("Could not initialize terminal"), theme_provider, @@ -49,8 +51,12 @@ impl Context { // -- getters - pub fn ft_params(&self) -> Option<&FileTransferParams> { - self.ft_params.as_ref() + pub fn remote_params(&self) -> Option<&FileTransferParams> { + self.remote_params.as_ref() + } + + pub fn host_bridge_params(&self) -> Option<&HostBridgeParams> { + self.host_bridge_params.as_ref() } pub fn bookmarks_client(&self) -> Option<&BookmarksClient> { @@ -91,8 +97,12 @@ impl Context { // -- setter - pub fn set_ftparams(&mut self, params: FileTransferParams) { - self.ft_params = Some(params); + pub fn set_remote_params(&mut self, params: FileTransferParams) { + self.remote_params = Some(params); + } + + pub fn set_host_bridge_params(&mut self, params: HostBridgeParams) { + self.host_bridge_params = Some(params); } // -- error diff --git a/src/utils/tty.rs b/src/utils/tty.rs index 289f992..03c1bbe 100644 --- a/src/utils/tty.rs +++ b/src/utils/tty.rs @@ -7,7 +7,7 @@ use tuirealm::terminal::TerminalBridge; /// Read a secret from tty with customisable prompt pub fn read_secret_from_tty( terminal_bridge: &mut TerminalBridge, - prompt: &str, + prompt: impl ToString, ) -> std::io::Result> { let _ = terminal_bridge.disable_raw_mode(); let _ = terminal_bridge.leave_alternate_screen();