diff --git a/Cargo.lock b/Cargo.lock index 175f9b1..4e1aef5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -302,6 +302,8 @@ dependencies = [ "lazy_static", "log", "maplit", + "serde", + "serde_json", "simplelog", "thiserror", ] diff --git a/espanso/Cargo.toml b/espanso/Cargo.toml index 1ea8f74..02080d6 100644 --- a/espanso/Cargo.toml +++ b/espanso/Cargo.toml @@ -32,4 +32,6 @@ clap = "2.33.3" lazy_static = "1.4.0" crossbeam = "0.8.0" enum-as-inner = "0.3.3" -dirs = "3.0.1" \ No newline at end of file +dirs = "3.0.1" +serde = { version = "1.0.123", features = ["derive"] } +serde_json = "1.0.62" \ No newline at end of file diff --git a/espanso/src/cli/worker/engine/executor/event_injector.rs b/espanso/src/cli/worker/engine/executor/event_injector.rs index e06bbe6..ef4be7e 100644 --- a/espanso/src/cli/worker/engine/executor/event_injector.rs +++ b/espanso/src/cli/worker/engine/executor/event_injector.rs @@ -39,6 +39,8 @@ impl <'a> TextInjector for EventInjectorAdapter<'a> { } fn inject_text(&self, text: &str) -> anyhow::Result<()> { + // TODO: wait for modifiers release + // Handle CRLF or LF line endings correctly let split_sequence = if text.contains("\r\n") { "\r\n" diff --git a/espanso/src/cli/worker/engine/executor/key_injector.rs b/espanso/src/cli/worker/engine/executor/key_injector.rs index 8fa907e..15a319e 100644 --- a/espanso/src/cli/worker/engine/executor/key_injector.rs +++ b/espanso/src/cli/worker/engine/executor/key_injector.rs @@ -33,6 +33,8 @@ impl<'a> KeyInjectorAdapter<'a> { impl<'a> KeyInjector for KeyInjectorAdapter<'a> { fn inject_sequence(&self, keys: &[crate::engine::event::input::Key]) -> anyhow::Result<()> { + // TODO: wait for modifiers release + let converted_keys: Vec<_> = keys.iter().map(convert_to_inject_key).collect(); self.injector.send_keys(&converted_keys, Default::default()) // TODO: handle options } diff --git a/espanso/src/cli/worker/engine/mod.rs b/espanso/src/cli/worker/engine/mod.rs index c5c136f..cb77ab1 100644 --- a/espanso/src/cli/worker/engine/mod.rs +++ b/espanso/src/cli/worker/engine/mod.rs @@ -22,6 +22,8 @@ use espanso_config::{config::ConfigStore, matches::store::MatchStore}; use espanso_path::Paths; use ui::selector::MatchSelectorAdapter; +use super::ui::icon::IconPaths; + pub mod executor; pub mod match_cache; pub mod matcher; @@ -30,10 +32,17 @@ pub mod render; pub mod source; pub mod ui; -pub fn initialize_and_spawn(paths: Paths, config_store: Box, match_store: Box) -> Result<()> { +pub fn initialize_and_spawn( + paths: Paths, + config_store: Box, + match_store: Box, + icon_paths: IconPaths, +) -> Result<()> { std::thread::Builder::new() .name("engine thread".to_string()) .spawn(move || { + // TODO: properly order the initializations if necessary + let app_info_provider = espanso_info::get_provider().expect("unable to initialize app info provider"); let config_manager = @@ -42,15 +51,19 @@ pub fn initialize_and_spawn(paths: Paths, config_store: Box, ma super::engine::matcher::convert::MatchConverter::new(&*config_store, &*match_store); let match_cache = super::engine::match_cache::MatchCache::load(&*config_store, &*match_store); - let detect_source = - super::engine::source::detect::init_and_spawn().expect("failed to initialize detector module"); + let modulo_manager = ui::modulo::ModuloManager::new(); + + let detect_source = super::engine::source::detect::init_and_spawn() + .expect("failed to initialize detector module"); let sources: Vec<&dyn crate::engine::funnel::Source> = vec![&detect_source]; let funnel = crate::engine::funnel::default(&sources); let matcher = super::engine::matcher::rolling::RollingMatcherAdapter::new( &match_converter.get_rolling_matches(), ); - let matchers: Vec<&dyn crate::engine::process::Matcher> = vec![&matcher]; + let matchers: Vec< + &dyn crate::engine::process::Matcher, + > = vec![&matcher]; let selector = MatchSelectorAdapter::new(); let multiplexer = super::engine::multiplex::MultiplexAdapter::new(&match_cache); @@ -72,6 +85,8 @@ pub fn initialize_and_spawn(paths: Paths, config_store: Box, ma &paths.packages, ); let shell_extension = espanso_render::extension::shell::ShellExtension::new(&paths.config); + let form_adapter = ui::modulo::form::ModuloFormProviderAdapter::new(&modulo_manager, icon_paths.form_icon); + let form_extension = espanso_render::extension::form::FormExtension::new(&form_adapter); let renderer = espanso_render::create(vec![ &clipboard_extension, &date_extension, @@ -79,6 +94,7 @@ pub fn initialize_and_spawn(paths: Paths, config_store: Box, ma &random_extension, &script_extension, &shell_extension, + &form_extension, ]); let renderer_adapter = super::engine::render::RendererAdapter::new(&match_cache, &config_manager, &renderer); @@ -92,11 +108,13 @@ pub fn initialize_and_spawn(paths: Paths, config_store: Box, ma &match_cache, ); - let event_injector = super::engine::executor::event_injector::EventInjectorAdapter::new(&*injector); - let clipboard_injector = super::engine::executor::clipboard_injector::ClipboardInjectorAdapter::new( - &*injector, - &*clipboard, - ); + let event_injector = + super::engine::executor::event_injector::EventInjectorAdapter::new(&*injector); + let clipboard_injector = + super::engine::executor::clipboard_injector::ClipboardInjectorAdapter::new( + &*injector, + &*clipboard, + ); let key_injector = super::engine::executor::key_injector::KeyInjectorAdapter::new(&*injector); let dispatcher = crate::engine::dispatch::default( &event_injector, diff --git a/espanso/src/cli/worker/engine/ui/mod.rs b/espanso/src/cli/worker/engine/ui/mod.rs index 441c687..4fa4de6 100644 --- a/espanso/src/cli/worker/engine/ui/mod.rs +++ b/espanso/src/cli/worker/engine/ui/mod.rs @@ -17,4 +17,5 @@ * along with espanso. If not, see . */ +pub mod modulo; pub mod selector; \ No newline at end of file diff --git a/espanso/src/cli/worker/engine/ui/modulo/form.rs b/espanso/src/cli/worker/engine/ui/modulo/form.rs new file mode 100644 index 0000000..cd60e13 --- /dev/null +++ b/espanso/src/cli/worker/engine/ui/modulo/form.rs @@ -0,0 +1,125 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2019-2021 Federico Terzi + * + * espanso is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * espanso is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with espanso. If not, see . + */ + +use std::{collections::HashMap, path::PathBuf}; + +use super::ModuloManager; +use anyhow::Result; +use espanso_render::extension::form::{FormProvider, FormProviderResult}; +use log::{error}; +use serde::Serialize; +use serde_json::{Map, Value}; + +pub struct ModuloFormProviderAdapter<'a> { + manager: &'a ModuloManager, + icon_path: Option, +} + +impl<'a> ModuloFormProviderAdapter<'a> { + pub fn new(manager: &'a ModuloManager, icon_path: Option) -> Self { + Self { + manager, + icon_path: icon_path.map(|path| path.to_string_lossy().to_string()), + } + } +} + +impl<'a> FormProvider for ModuloFormProviderAdapter<'a> { + fn show( + &self, + layout: &str, + fields: &espanso_render::Params, + _: &espanso_render::Params, + ) -> FormProviderResult { + let modulo_form_config = ModuloFormConfig { + icon: self.icon_path.as_deref(), + title: "espanso", + layout, + fields: convert_params_into_object(fields), + }; + + match serde_json::to_string(&modulo_form_config) { + Ok(json_config) => { + match self + .manager + .invoke(&["form", "-j", "-i", "-"], &json_config) + { + Ok(output) => { + let json: Result, _> = serde_json::from_str(&output); + match json { + Ok(json) => { + if json.is_empty() { + return FormProviderResult::Aborted; + } else { + return FormProviderResult::Success(json); + } + } + Err(error) => { + return FormProviderResult::Error(error.into()); + } + } + } + Err(err) => { + return FormProviderResult::Error(err.into()); + } + } + } + Err(err) => { + return FormProviderResult::Error(err.into()); + } + } + } +} + +#[derive(Debug, Serialize)] +struct ModuloFormConfig<'a> { + icon: Option<&'a str>, + title: &'a str, + layout: &'a str, + fields: Map, +} + +// TODO: test +fn convert_params_into_object(params: &espanso_render::Params) -> Map { + let mut obj = Map::new(); + for (field, value) in params { + obj.insert(field.clone(), convert_value(value)); + } + obj +} + +// TODO: test +fn convert_value(value: &espanso_render::Value) -> Value { + match value { + espanso_render::Value::Null => Value::Null, + espanso_render::Value::Bool(value) => Value::Bool(*value), + espanso_render::Value::Number(num) => match num { + espanso_render::Number::Integer(val) => Value::Number((*val).into()), + espanso_render::Number::Float(val) => { + Value::Number(serde_json::Number::from_f64(*val).unwrap_or_else(|| { + error!("unable to convert float value to json"); + 0.into() + })) + } + }, + espanso_render::Value::String(value) => Value::String(value.clone()), + espanso_render::Value::Array(arr) => Value::Array(arr.into_iter().map(convert_value).collect()), + espanso_render::Value::Object(obj) => Value::Object(convert_params_into_object(obj)), + } +} diff --git a/espanso/src/cli/worker/engine/ui/modulo/mod.rs b/espanso/src/cli/worker/engine/ui/modulo/mod.rs new file mode 100644 index 0000000..d809fa7 --- /dev/null +++ b/espanso/src/cli/worker/engine/ui/modulo/mod.rs @@ -0,0 +1,155 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2019-2021 Federico Terzi + * + * espanso is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * espanso is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with espanso. If not, see . + */ + +use anyhow::Result; +use log::{error, info}; +use std::io::Write; +use std::process::Command; +use thiserror::Error; + +pub mod form; + +pub struct ModuloManager { + modulo_path: Option, +} + +impl ModuloManager { + pub fn new() -> Self { + let mut modulo_path: Option = None; + // Check if the `MODULO_PATH` env variable is configured + if let Some(_modulo_path) = std::env::var_os("MODULO_PATH") { + info!("using modulo from env variable at {:?}", _modulo_path); + modulo_path = Some(_modulo_path.to_string_lossy().to_string()) + } else { + // Check in the same directory of espanso + if let Ok(exe_path) = std::env::current_exe() { + if let Some(parent) = exe_path.parent() { + let possible_path = parent.join("modulo"); + let possible_path = possible_path.to_string_lossy().to_string(); + + if let Ok(output) = Command::new(&possible_path).arg("--version").output() { + if output.status.success() { + info!("using modulo from exe directory at {:?}", possible_path); + modulo_path = Some(possible_path); + } + } + } + } + + // Otherwise check if present in the PATH + if modulo_path.is_none() { + if let Ok(output) = Command::new("modulo").arg("--version").output() { + if output.status.success() { + info!("using modulo executable found in PATH"); + modulo_path = Some("modulo".to_owned()); + } + } + } + } + + Self { modulo_path } + } + + // pub fn is_valid(&self) -> bool { + // self.modulo_path.is_some() + // } + + // pub fn get_version(&self) -> Option { + // if let Some(ref modulo_path) = self.modulo_path { + // if let Ok(output) = Command::new(modulo_path).arg("--version").output() { + // let version = String::from_utf8_lossy(&output.stdout); + // return Some(version.to_string()); + // } + // } + + // None + // } + + pub fn invoke(&self, args: &[&str], body: &str) -> Result { + if let Some(modulo_path) = &self.modulo_path { + let mut command = Command::new(modulo_path); + command + .args(args) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + crate::util::set_command_flags(&mut command); + + let child = command.spawn(); + + match child { + Ok(mut child) => { + if let Some(stdin) = child.stdin.as_mut() { + match stdin.write_all(body.as_bytes()) { + Ok(_) => { + // Get the output + match child.wait_with_output() { + Ok(child_output) => { + let output = String::from_utf8_lossy(&child_output.stdout); + + // Check also if the program reports an error + let error = String::from_utf8_lossy(&child_output.stderr); + if !error.is_empty() { + error!("modulo reported an error: {}", error); + } + + if !output.trim().is_empty() { + return Ok(output.to_string()); + } else { + return Err(ModuloError::EmptyOutput.into()); + } + } + Err(error) => { + return Err(ModuloError::Error(error).into()); + } + } + } + Err(error) => { + return Err(ModuloError::Error(error).into()); + } + } + } else { + return Err(ModuloError::StdinError.into()); + } + } + Err(error) => { + return Err(ModuloError::Error(error).into()); + } + } + } else { + return Err(ModuloError::MissingModulo.into()); + } + } +} + +#[derive(Error, Debug)] +pub enum ModuloError { + #[error("attempt to invoke modulo even though it's not configured")] + MissingModulo, + + #[error("modulo returned an empty output")] + EmptyOutput, + + #[error("could not connect to modulo stdin")] + StdinError, + + #[error("error occurred during modulo invocation")] + Error(#[from] std::io::Error), +} diff --git a/espanso/src/cli/worker/mod.rs b/espanso/src/cli/worker/mod.rs index fca51be..561b84f 100644 --- a/espanso/src/cli/worker/mod.rs +++ b/espanso/src/cli/worker/mod.rs @@ -55,6 +55,7 @@ fn worker_main(args: CliModuleArgs) { icon_paths: convert_icon_paths_to_tray_vec(&icon_paths), notification_icon_path: icon_paths .logo + .as_ref() .map(|path| path.to_string_lossy().to_string()), ..Default::default() }) @@ -66,7 +67,7 @@ fn worker_main(args: CliModuleArgs) { // TODO: pass the remote // Initialize the engine on another thread and start it - engine::initialize_and_spawn(paths.clone(), config_store, match_store) + engine::initialize_and_spawn(paths.clone(), config_store, match_store, icon_paths) .expect("unable to initialize engine"); eventloop.run(Box::new(move |event| { diff --git a/espanso/src/cli/worker/ui/icon.rs b/espanso/src/cli/worker/ui/icon.rs index 20fcc8b..9410052 100644 --- a/espanso/src/cli/worker/ui/icon.rs +++ b/espanso/src/cli/worker/ui/icon.rs @@ -33,6 +33,8 @@ const WINDOWS_RED_ICO_BINARY: &[u8] = include_bytes!("../../../res/windows/espan #[derive(Debug, Default)] pub struct IconPaths { + pub form_icon: Option, + pub tray_icon_normal: Option, pub tray_icon_disabled: Option, pub tray_icon_system_disabled: Option, // TODO: secure input @@ -43,6 +45,7 @@ pub struct IconPaths { #[cfg(target_os = "windows")] pub fn load_icon_paths(runtime_dir: &Path) -> Result { Ok(IconPaths { + form_icon: Some(extract_icon(WINDOWS_ICO_BINARY, &runtime_dir.join("form.ico"))?), tray_icon_normal: Some(extract_icon(WINDOWS_ICO_BINARY, &runtime_dir.join("normal.ico"))?), tray_icon_disabled: Some(extract_icon(WINDOWS_RED_ICO_BINARY, &runtime_dir.join("disabled.ico"))?), logo: Some(extract_icon(ICON_BINARY, &runtime_dir.join("icon.png"))?), diff --git a/espanso/src/main.rs b/espanso/src/main.rs index 9d19acb..1682255 100644 --- a/espanso/src/main.rs +++ b/espanso/src/main.rs @@ -28,6 +28,7 @@ use simplelog::{ }; mod cli; +mod util; mod engine; mod logging; diff --git a/espanso/src/util.rs b/espanso/src/util.rs new file mode 100644 index 0000000..99a9251 --- /dev/null +++ b/espanso/src/util.rs @@ -0,0 +1,33 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2019-2021 Federico Terzi + * + * espanso is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * espanso is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with espanso. If not, see . + */ + +use std::process::Command; + +#[cfg(target_os = "windows")] +pub fn set_command_flags(command: &mut Command) { + use std::os::windows::process::CommandExt; + // Avoid showing the shell window + // See: https://github.com/federico-terzi/espanso/issues/249 + command.creation_flags(0x08000000); +} + +#[cfg(not(target_os = "windows"))] +pub fn set_command_flags(_: &mut Command) { + // NOOP on Linux and macOS +} \ No newline at end of file