diff options
author | Canop <cano.petrole@gmail.com> | 2020-07-20 20:10:56 +0200 |
---|---|---|
committer | Canop <cano.petrole@gmail.com> | 2020-07-20 20:10:56 +0200 |
commit | 84d857e7fa6e8749c21b3c937c96cce9c25356e9 (patch) | |
tree | 878688bea2b4c54fbcd5b996cf5d098194e0a399 | |
parent | f0cd893b028cec09bd7a35b3f4d00c64c31cea0f (diff) |
"client-server" feature
-rw-r--r-- | CHANGELOG.md | 1 | ||||
-rw-r--r-- | Cargo.toml | 4 | ||||
-rw-r--r-- | client-server.md | 58 | ||||
-rw-r--r-- | src/app/app.rs | 102 | ||||
-rw-r--r-- | src/clap.rs | 19 | ||||
-rw-r--r-- | src/cli.rs | 34 | ||||
-rw-r--r-- | src/command/mod.rs | 2 | ||||
-rw-r--r-- | src/command/sequence.rs | 135 | ||||
-rw-r--r-- | src/errors.rs | 10 | ||||
-rw-r--r-- | src/lib.rs | 4 | ||||
-rw-r--r-- | src/net/client.rs | 32 | ||||
-rw-r--r-- | src/net/message.rs | 64 | ||||
-rw-r--r-- | src/net/mod.rs | 15 | ||||
-rw-r--r-- | src/net/server.rs | 75 | ||||
-rw-r--r-- | src/task_sync.rs | 29 |
15 files changed, 484 insertions, 100 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index cfa89b1..0b4a95e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ### next version - preview's pattern is kept when changing file +- "client-server" feature (see client-server.md) <a name="v0.19.1"></a> ### v0.19.1 - 2020-07-17 @@ -13,6 +13,10 @@ readme = "README.md" build = "build.rs" exclude = ["website", "broot*.zip"] +[features] +default = [] +client-server = [] + [dependencies] bet = "0.3.4" clap = { version="2.33", default-features=false, features=["suggestions"] } diff --git a/client-server.md b/client-server.md new file mode 100644 index 0000000..9734b62 --- /dev/null +++ b/client-server.md @@ -0,0 +1,58 @@ + +# Presentation + +`"client-server"` is a feature declared in Cargo.toml. + +It's not enabled by default. + +If you want to compile with the feature, do + + cargo build --release --features "client-server" + +To run in debug mode with the feature, do + + cargo run --features "client-server" -- + +This feature add 2 launch arguments that you can see with + + cargo run --features "client-server" -- --help + +Those arguments are: + +* `--listen <instance_name>` : listen on a specific socket +* `--send <instance_name>`: send the command(s) to the given server and quit + +For example if you start broot with + + br --listen my_broot + +broot will run normally but will *also* listen to commands sent from elsewhere (using linux sockets). + +If you want to do the same in debug mode, it's + + cargo run --features "client-server" -- --listen my_broot + +Now that the "server" is running, try + + br --send my_broot -c "img;:parent;:focus" + +this will make the running "server" search for something like "img" and focus its parent. + +If you don't pass the `--cmd` (shortened in `-c`) argument, then the server is told to focus the current directory or the path given as argument. + +# Development + +This feature started here: https://github.com/Canop/broot/issues/225 + +and is being discussed and developped between @Canop (@dystroy on Miaou) and @SRGOM (@fiAtcBr on Miaou) and you're welcome to contribute on [Miaou](https://miaou.dystroy.org/3490). + +# TODO: + +- [ ] merge in master ? +- [ ] have convincing use cases described +- [ ] implement a command "GetRoot" to +- [ ] have convincing use cases implemented +- [ ] make available with TCP localhost sockets on windows ? +- [ ] stop hiding behing a compilation flag + + diff --git a/src/app/app.rs b/src/app/app.rs index 4232414..5d1187c 100644 --- a/src/app/app.rs +++ b/src/app/app.rs @@ -2,16 +2,17 @@ use { super::*, crate::{ browser::BrowserState, - command::{parse_command_sequence, Command}, + command::{Command, Sequence}, conf::Conf, display::{Areas, Screen, W}, errors::ProgramError, file_sum, git, launchable::Launchable, skin::*, - task_sync::Dam, + task_sync::{Dam, Either}, verb::Internal, }, + crossbeam::channel::unbounded, crossterm::event::KeyModifiers, std::{ io::Write, @@ -379,23 +380,19 @@ impl App { screen.clear_bottom_right_char(w, &skin.focused)?; - // if some commands were passed to the application - // we execute them before even starting listening for events - if let Some(unparsed_commands) = &con.launch_args.commands { - for (input, arg_cmd) in parse_command_sequence(unparsed_commands, con)? { - self.mut_panel().set_input_content(input); - self.apply_command(w, arg_cmd, screen, &skin.focused, con)?; - self.display_panels(w, screen, &skin, con)?; - w.flush()?; - self.do_pending_tasks(screen, con, &mut dam)?; - self.display_panels(w, screen, &skin, con)?; - w.flush()?; - if self.quitting { - return Ok(self.launch_at_end.take()); - } - } + // we create a channel for unparsed raw sequence which may come + // from the --cmd argument or from the server module + let (tx_seqs, rx_seqs) = unbounded::<Sequence>(); + + if let Some(raw_sequence) = &con.launch_args.commands { + tx_seqs.send(Sequence::new_local(raw_sequence.to_string())).unwrap(); } + #[cfg(feature="client-server")] + let _server = con.launch_args.listen.as_ref() + .map(|server_name| crate::net::Server::new(&server_name, tx_seqs.clone())) + .transpose()?; + loop { if !self.quitting { self.display_panels(w, screen, &skin, con)?; @@ -405,39 +402,60 @@ impl App { w.flush()?; } } - let event = match dam.next_event() { - Some(event) => event, - None => { + + match dam.next(&rx_seqs) { + Either::First(Some(event)) => { + debug!("event: {:?}", &event); + match event { + Event::Click(x, y, KeyModifiers::NONE) + if self.clicked_panel_index(x, y, screen) != self.active_panel_idx => + { + // panel activation click + // this will be cleaner when if let will be allowed in match guards with + // chaining (currently experimental) + self.active_panel_idx = self.clicked_panel_index(x, y, screen); + } + Event::Resize(w, h) => { + screen.set_terminal_size(w, h, con); + Areas::resize_all(self.panels.as_mut_slice(), screen, self.preview.is_some())?; + for panel in &mut self.panels { + panel.mut_state().refresh(screen, con); + } + } + _ => { + // event handled by the panel + let cmd = self.mut_panel().add_event(w, event, con)?; + debug!("command after add_event: {:?}", &cmd); + self.apply_command(w, cmd, screen, &skin.focused, con)?; + } + } + event_source.unblock(self.quitting); + } + Either::First(None) => { // this is how we quit the application, // when the input thread is properly closed break; } - }; - debug!("event: {:?}", &event); - match event { - Event::Click(x, y, KeyModifiers::NONE) - if self.clicked_panel_index(x, y, screen) != self.active_panel_idx => - { - // panel activation clic - // this will be cleaner when if let will be allowed in match guards with - // chaining (currently experimental) - self.active_panel_idx = self.clicked_panel_index(x, y, screen); - } - Event::Resize(w, h) => { - screen.set_terminal_size(w, h, con); - Areas::resize_all(self.panels.as_mut_slice(), screen, self.preview.is_some())?; - for panel in &mut self.panels { - panel.mut_state().refresh(screen, con); + Either::Second(Some(raw_sequence)) => { + debug!("got sequence: {:?}", &raw_sequence); + for (input, arg_cmd) in raw_sequence.parse(con)? { + self.mut_panel().set_input_content(&input); + self.apply_command(w, arg_cmd, screen, &skin.focused, con)?; + self.display_panels(w, screen, &skin, con)?; + w.flush()?; + self.do_pending_tasks(screen, con, &mut dam)?; + self.display_panels(w, screen, &skin, con)?; + w.flush()?; + if self.quitting { + // is that a 100% safe way of quitting ? + return Ok(self.launch_at_end.take()); + } } } - _ => { - // event handled by the panel - let cmd = self.mut_panel().add_event(w, event, con)?; - debug!("command after add_event: {:?}", &cmd); - self.apply_command(w, cmd, screen, &skin.focused, con)?; + Either::Second(None) => { + warn!("I didn't expect a None to occur here"); } } - event_source.unblock(self.quitting); } Ok(self.launch_at_end.take()) diff --git a/src/clap.rs b/src/clap.rs index 94dea93..f1e2f69 100644 --- a/src/clap.rs +++ b/src/clap.rs @@ -3,7 +3,7 @@ /// declare the possible CLI arguments pub fn clap_app() -> clap::App<'static, 'static> { - clap::App::new("broot") + let app = clap::App::new("broot") .version(env!("CARGO_PKG_VERSION")) .author("dystroy <denys.seguret@gmail.com>") .about("A tree explorer and a customizable launcher") @@ -194,5 +194,20 @@ pub fn clap_app() -> clap::App<'static, 'static> { .value_name("shell") .help("Print to stdout the br function for a given shell"), ) - .setting(clap::AppSettings::DeriveDisplayOrder) + .setting(clap::AppSettings::DeriveDisplayOrder); + #[cfg(feature="client-server")] + let app = app + .arg( + clap::Arg::with_name("listen") + .long("listen") + .takes_value(true) + .help("Listen for commands") + ) + .arg( + clap::Arg::with_name("send") + .long("send") + .takes_value(true) + .help("send command and quits") + ); + app } @@ -26,6 +26,7 @@ use { }, }; + /// launch arguments related to installation /// (not used by the application after the first step) struct InstallLaunchArgs { @@ -56,11 +57,14 @@ impl InstallLaunchArgs { pub struct AppLaunchArgs { pub root: PathBuf, // what should be the initial root pub file_export_path: Option<String>, // where to write the produced path (if required with --out) - pub cmd_export_path: Option<String>, // where to write the produced command (if required with --outcmd) - pub tree_options: TreeOptions, // initial tree options - pub commands: Option<String>, // commands passed as cli argument, still unparsed - pub height: Option<u16>, // an optional height to replace the screen's one - pub no_style: bool, // whether to remove all styles (including colors) + pub cmd_export_path: Option<String>, // where to write the produced command (if required with --outcmd) + pub tree_options: TreeOptions, // initial tree options + pub commands: Option<String>, // commands passed as cli argument, still unparsed + pub height: Option<u16>, // an optional height to replace the screen's one + pub no_style: bool, // whether to remove all styles (including colors) + + #[cfg(feature="client-server")] + pub listen: Option<String>, } #[cfg(not(windows))] @@ -182,6 +186,23 @@ pub fn run() -> Result<Option<Launchable>, ProgramError> { let root = get_root_path(&cli_matches)?; + #[cfg(feature="client-server")] + if let Some(server_name) = cli_matches.value_of("send") { + use crate::{ + command::Sequence, + net::{Client, Message}, + }; + let message = if let Some(seq) = &commands { + Message::Sequence(Sequence::new_local(seq.to_string())) + } else { + Message::Command(format!(":focus {}", root.to_string_lossy())) + }; + let client = Client::new(server_name); + debug!("sending {:?}", &message); + client.send(&message)?; + return Ok(None); + } + let launch_args = AppLaunchArgs { root, file_export_path, @@ -190,6 +211,9 @@ pub fn run() -> Result<Option<Launchable>, ProgramError> { commands, height, no_style, + + #[cfg(feature="client-server")] + listen: cli_matches.value_of("listen").map(str::to_string), }; let context = AppContext::from(launch_args, verb_store, &config); diff --git a/src/command/mod.rs b/src/command/mod.rs index 98fe403..7655b92 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -11,7 +11,7 @@ pub use { completion::Completions, event::PanelInput, parts::CommandParts, - sequence::parse_command_sequence, + sequence::Sequence, scroll::ScrollCommand, trigger_type::TriggerType, }; diff --git a/src/command/sequence.rs b/src/command/sequence.rs index 3b51738..a8d8fe0 100644 --- a/src/command/sequence.rs +++ b/src/command/sequence.rs @@ -10,58 +10,95 @@ use { }, }; -/// parse a string which is meant as a sequence of commands. -/// -/// The ';' separator is used to identify inputs unless it's -/// overriden in env variable BROOT_CMD_SEPARATOR. -/// Verbs are verified, to ensure the command sequence has -/// no unexpected holes. -pub fn parse_command_sequence<'a>( - sequence: &'a str, - con: &AppContext, -) -> Result<Vec<(&'a str, Command)>, ProgramError> { - let separator = match std::env::var("BROOT_CMD_SEPARATOR") { - Ok(sep) if !sep.is_empty() => sep, - _ => String::from(";"), - }; - debug!("Splitting cmd sequence with {:?}", separator); - let mut commands = Vec::new(); - for input in sequence.split(&separator) { - // an input may be made of two parts: - // - a search pattern - // - a verb followed by its arguments - // we need to build a command for each part so - // that the search is effectively done before - // the verb invocation - let raw_parts = CommandParts::from(input.to_string()); - let (pattern, verb_invocation) = raw_parts.split(); - if let Some(pattern) = pattern { - debug!("adding pattern: {:?}", pattern); - commands.push((input, Command::from_parts(pattern, false))); +/// an unparsed sequence with its separator (which may be +/// different from the one provided by local_separator()) +#[derive(Debug)] +pub struct Sequence { + pub separator: String, + pub raw: String, +} + +impl Sequence { + /// return the separator to use to parse sequences. + pub fn local_separator() -> String { + match std::env::var("BROOT_CMD_SEPARATOR") { + Ok(sep) if !sep.is_empty() => sep, + _ => String::from(";"), } - if let Some(verb_invocation) = verb_invocation { - debug!("adding verb_invocation: {:?}", verb_invocation); - let command = Command::from_parts(verb_invocation, true); - if let Command::VerbInvocate(invocation) = &command { - // we check that the verb exists to avoid running a sequence - // of actions with some missing - match con.verb_store.search(&invocation.name) { - PrefixSearchResult::NoMatch => { - return Err(ProgramError::UnknownVerb { - name: invocation.name.to_string(), - }); - } - PrefixSearchResult::Matches(_) => { - return Err(ProgramError::AmbiguousVerbName { - name: invocation.name.to_string(), - }); - } - _ => {} - } - commands.push((input, command)); + } + pub fn new(separator: String, raw: String) -> Self { + Self { + separator, + raw, + } + } + pub fn new_single(cmd: String) -> Self { + Self { + separator: "".to_string(), + raw: cmd, + } + } + pub fn new_local(raw: String) -> Self { + Self { + separator: Self::local_separator(), + raw, + } + } + pub fn parse( + &self, + con: &AppContext, + ) -> Result<Vec<(String, Command)>, ProgramError> { + debug!("Splitting cmd sequence with {:?}", &self.separator); + let mut commands = Vec::new(); + if self.separator.is_empty() { + add_commands(&self.raw, &mut commands, con)?; + } else { + for input in self.raw.split(&self.separator) { + add_commands(input, &mut commands, con)?; } } + Ok(commands) } - Ok(commands) } +/// an input may be made of two parts: +/// - a search pattern +/// - a verb followed by its arguments +/// we need to build a command for each part so +/// that the search is effectively done before +/// the verb invocation +fn add_commands( + input: &str, + commands: &mut Vec<(String, Command)>, + con: &AppContext, +) -> Result<(), ProgramError> { + let raw_parts = CommandParts::from(input.to_string()); + let (pattern, verb_invocation) = raw_parts.split(); + if let Some(pattern) = pattern { + debug!("adding pattern: {:?}", pattern); + commands.push((input.to_string(), Command::from_parts(pattern, false))); + } + if let Some(verb_invocation) = verb_invocation { + debug!("adding verb_invocation: {:?}", verb_invocation); + let command = Command::from_parts(verb_invocation, true); + if let Command::VerbInvocate(invocation) = &command { + // we check that the verb exists to avoid running a sequence + // of actions with some missing + match con.verb_store.search(&invocation.name) { + PrefixSearchResult::NoMatch => { + return Err(ProgramError::UnknownVerb { + name: invocation.name.to_string(), + }); + } + PrefixSearchResult::Matches(_) => { + return Err(ProgramError::AmbiguousVerbName { + name: invocation.name.to_string(), + }); + } + _ => {} + } + commands.push((input.to_string(), command)); + } + } + Ok(()) +} diff --git a/src/errors.rs b/src/errors.rs index 640fb0e..55ad8ba 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -22,6 +22,7 @@ custom_error! {pub ProgramError TerminalTooSmallError = "Terminal too small", // unable to open panel or app InvalidGlobError {pattern: String} = "Invalid glob: {pattern}", Unrecognized {token: String} = "Unrecognized: {token}", + NetError {source: NetError} = "{}", } custom_error! {pub TreeBuildError @@ -30,7 +31,7 @@ custom_error! {pub TreeBuildError } custom_error! {pub ConfError - Io {source: io::Error} = "unable to read from the file", + Io {source: io::Error} = "unable to read from the file: {}", Toml {source: toml::de::Error} = "unable to parse TOML", MissingField {txt: String} = "missing field in conf", InvalidVerbInvocation {invocation: String} = "invalid verb invocation: {}", @@ -57,3 +58,10 @@ custom_error! {pub InvalidSkinError InvalidGreyLevel { level: u8 } = "grey level must be between 0 and 23 (got {})", InvalidStyle {style: String} = "Invalid skin style : {}", } + +custom_error! {pub NetError + SocketNotAvailable { path : String } = "Can't open socket: {} already exists - consider removing it", + Io {source: io::Error} = "error on the socket: {}", + InvalidMessage = "invalid message received", +} + @@ -34,6 +34,10 @@ pub mod flag; pub mod help; pub mod keys; pub mod launchable; + +#[cfg(feature="client-server")] +pub mod net; + pub mod path; pub mod path_anchor; pub mod pattern; diff --git a/src/net/client.rs b/src/net/client.rs new file mode 100644 index 0000000..2e330e0 --- /dev/null +++ b/src/net/client.rs @@ -0,0 +1,32 @@ + +use { + super::{ + Message, + }, + crate::{ + errors::NetError, + }, + std::{ + os::unix::net::{ + UnixStream, + }, + }, +}; + +pub struct Client { + path: String, +} + +impl Client { + pub fn new(socket_name: &str) -> Self { + Self { + path: super::socket_file_path(socket_name), + } + } + pub fn send(&self, message: &Message) -> Result<(), NetError> { + debug!("try connecting {:?}", &self.path); + let mut stream = UnixStream::connect(&self.path)?; + message.write(&mut stream)?; + Ok(()) + } +} diff --git a/src/net/message.rs b/src/net/message.rs new file mode 100644 index 0000000..20d1ed5 --- /dev/null +++ b/src/net/message.rs @@ -0,0 +1,64 @@ +use { + crate::{ + errors::NetError, + command::Sequence, + }, + std::{ + io::{ + self, + BufRead, + Write, + }, + }, +}; + +/// A message which may be sent by a +#[derive(Debug)] +pub enum Message { + Command(String), + Hi, + //GetRoot + Sequence(Sequence), +} + +fn read_line<BR: BufRead>(r: &mut BR) -> Result<String, NetError> { + let mut line = String::new(); + r.read_line(&mut line)?; + debug!("read line => {:?}", &line); + while line.ends_with('\n') || line.ends_with('\r') { + line.pop(); + } + Ok(line) +} + +impl Message { + pub fn read<BR: BufRead>(r: &mut BR) -> Result<Self, NetError> { + // the first line gives the type of message + match read_line(r)?.as_ref() { + "CMD" => Ok(Self::Command( + read_line(r)?, + )), + "SEQ" => Ok(Self::Sequence(Sequence::new( + read_line(r)?, + read_line(r)?, + ))), + _ => Err(NetError::InvalidMessage), + } + } + pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> { + match self { + Self::Command(c) => { + writeln!(w, "CMD")?; + writeln!(w, "{}", c) + } + Self::Hi => { + writeln!(w, "HI") + } + Self::Sequence(Sequence { separator, raw }) => { + writeln!(w, "SEQ")?; + writeln!(w, "{}", separator)?; + writeln!(w, "{}", raw) + } + } + } +} diff --git a/src/net/mod.rs b/src/net/mod.rs new file mode 100644 index 0000000..9610c6e --- /dev/null +++ b/src/net/mod.rs @@ -0,0 +1,15 @@ + + +mod client; +mod message; +mod server; + +pub use { + client::Client, + message::Message, + server::Server, +}; + +pub fn socket_file_path(server_name: &str) -> String { + format!("/tmp/broot-server-{}.sock", server_name) +} diff --git a/src/net/server.rs b/src/net/server.rs new file mode 100644 index 0000000..3ae5247 --- /dev/null +++ b/src/net/server.rs @@ -0,0 +1,75 @@ +use { + super::Message, + crate::{ + errors::NetError, + command::Sequence, + }, + crossbeam::channel::Sender, + std::{ + fs, + io::BufReader, + os::unix::net::UnixListener, + thread, + }, +}; + +pub struct Server { + path: String, +} + +impl Server { + pub fn new(name: &str, tx: Sender<Sequence>) -> Result<Self, NetError> { + let path = super::socket_file_path(name); + if fs::metadata(&path).is_ok() { + return Err(NetError::SocketNotAvailable { path }); + } + let listener = UnixListener::bind(&path)?; + debug!("listening on {}", &path); + + // we use only one thread as we don't want to support long connections + thread::spawn(move || { + for stream in listener.incoming() { + match stream { + Ok(stream) => { + let mut br = BufReader::new(stream); + if let Some(sequence) = match Message::read(&mut br) { + Ok(Message::Command(command)) => { + debug!("got single command {:?}", &command); + // we convert it to a sequence + Some(Sequence::new_single(command)) + } + Ok(Message::Sequence(sequence)) => { + debug!("got sequence {:?}", &sequence); + Some(sequence) + } + Ok(message) => { + debug!("got something not yet handled: {:?}", message); + None + } + Err(e) => { + warn!("Read error : {:?}", e); + None + } + } { + if let Err(e) = tx.send(sequence) { + warn!("error while sending {:?}", e); + return; + } + } + } + Err(e) => { + warn!("Stream error : {:?}", e); + } + } + } + }); + Ok(Self { path }) + } +} + +impl Drop for Server { + fn drop(&mut self) { + debug!("removing socket file"); + fs::remove_file(&self.path).unwrap(); + } +} diff --git a/src/task_sync.rs b/src/task_sync.rs index c1fbd6f..fcd121d 100644 --- a/src/task_sync.rs +++ b/src/task_sync.rs @@ -4,6 +4,11 @@ use { termimad::Event, }; +pub enum Either<A, B> { + First(A), + Second(B), +} + #[derive(Debug, Clone)] pub enum ComputationResult<V> { NotComputed, // not computed but will probably be @@ -129,6 +134,30 @@ impl Dam { } } } + + // or maybed return either Option<Event> or Option<T> ? + pub fn next<T>(&mut self, other: &Receiver<T>) -> Either<Option<Event>, Option<T>> { + if self.in_dam.is_some() { + Either::First(self.in_dam.take()) + } else { + select! { + recv(self.receiver) -> event => Either::First(match event { + Ok(event) => Some(event), + Err(_) => { + debug!("dead dam"); // should be logged once + None + } + }), + recv(other) -> o => Either::Second(match o { + Ok(o) => Some(o), + Err(_) => { + debug!("dead other"); + None + } + }), + } + } + } } pub struct DamObserver { |