Repository: espanso/espanso (language: rust)

Issue:
Espanso cannot be built on ARM Linux
Currently, Espanso can't be compiled on ARM linux due to an incorrect cast to `i8`:

```
  Compiling espanso-render v0.1.0 (/build/espanso/parts/espanso/build/espanso-render)
   Compiling espanso-migrate v0.1.0 (/build/espanso/parts/espanso/build/espanso-migrate)
error[E0308]: mismatched types
   --> espanso-detect/src/evdev/device.rs:168:9
    |
168 |         buffer.as_mut_ptr() as *mut i8,
    |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `u8`, found `i8`
    |
    = note: expected raw pointer `*mut u8`
               found raw pointer `*mut i8`

error[E0308]: mismatched types
   --> espanso-detect/src/evdev/device.rs:172:47
    |
172 |     let content_raw = unsafe { CStr::from_ptr(buffer.as_ptr() as *mut i8) };
    |                                               ^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `u8`, found `i8`
    |
    = note: expected raw pointer `*const u8`
               found raw pointer `*mut i8`

For more information about this error, try `rustc --explain E0308`.
error: could not compile `espanso-detect` due to 2 previous errors
warning: build failed, waiting for other jobs to finish...
error: build failed
```

We should use c_char instead, which adapts to the current platform


Discussion / hints:
It seems that it is not only that I have encountered exactly the same problem (I failed to install version 2.0.1 on the arm machine), I am following it continuously.
The next version v2.0.4-alpha will include a fix for this problem, thank you for the help! :)
Reopening because it still fails with a different error:

```
error[E0308]: mismatched types
  --> espanso-inject/src/evdev/state.rs:56:9
   |
56 |         buffer.as_mut_ptr() as *mut i8,
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `u8`, found `i8`
   |
   = note: expected raw pointer `*mut u8`
              found raw pointer `*mut i8`

error[E0308]: mismatched types
  --> espanso-inject/src/evdev/state.rs:61:49
   |
61 |       let content_raw = unsafe { CStr::from_ptr(buffer.as_ptr() as *mut i8) };
   |                                                 ^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `u8`, found `i8`
   |
   = note: expected raw pointer `*const u8`
              found raw pointer `*mut i8`
```

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 716e52ca..9572b17a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -572,7 +572,7 @@ dependencies = [
 
 [[package]]
 name = "espanso"
-version = "2.0.4-alpha"
+version = "2.0.5-alpha"
 dependencies = [
  "anyhow",
  "caps",
diff --git a/espanso-clipboard/src/cocoa/native.mm b/espanso-clipboard/src/cocoa/native.mm
index b60f56d1..91ed16a7 100644
--- a/espanso-clipboard/src/cocoa/native.mm
+++ b/espanso-clipboard/src/cocoa/native.mm
@@ -44,7 +44,11 @@ int32_t clipboard_set_text(char * text) {
   [pasteboard declareTypes:array owner:nil];
 
   NSString *nsText = [NSString stringWithUTF8String:text];
-  [pasteboard setString:nsText forType:NSPasteboardTypeString];
+  if (![pasteboard setString:nsText forType:NSPasteboardTypeString]) {
+    return 0;
+  }
+
+  return 1;
 }
 
 int32_t clipboard_set_image(char * image_path) {
diff --git a/espanso-clipboard/src/wayland/fallback/mod.rs b/espanso-clipboard/src/wayland/fallback/mod.rs
index 157bdd59..c916dacf 100644
--- a/espanso-clipboard/src/wayland/fallback/mod.rs
+++ b/espanso-clipboard/src/wayland/fallback/mod.rs
@@ -26,7 +26,7 @@ use std::{
 
 use crate::{Clipboard, ClipboardOptions};
 use anyhow::Result;
-use log::error;
+use log::{error, warn};
 use std::process::Command;
 use thiserror::Error;
 use wait_timeout::ChildExt;
@@ -49,7 +49,15 @@ impl WaylandFallbackClipboard {
 
     // Try to connect to the wayland display
     let wayland_socket = if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
-      PathBuf::from(runtime_dir).join("wayland-0")
+      let wayland_display = if let Ok(display) = std::env::var("WAYLAND_DISPLAY") {
+        display
+      } else {
+        warn!("Could not determine wayland display from WAYLAND_DISPLAY env variable, falling back to 'wayland-0'");
+        warn!("Note that this might not work on some systems.");
+        "wayland-0".to_string()
+      };
+
+      PathBuf::from(runtime_dir).join(wayland_display)
     } else {
       error!("environment variable XDG_RUNTIME_DIR is missing, can't initialize the clipboard");
       return Err(WaylandFallbackClipboardError::MissingEnvVariable().into());
diff --git a/espanso-detect/src/evdev/device.rs b/espanso-detect/src/evdev/device.rs
index 818578dd..5723cccd 100644
--- a/espanso-detect/src/evdev/device.rs
+++ b/espanso-detect/src/evdev/device.rs
@@ -2,7 +2,7 @@
 // https://github.com/xkbcommon/libxkbcommon/blob/master/tools/interactive-evdev.c
 
 use anyhow::Result;
-use libc::{input_event, size_t, ssize_t, EWOULDBLOCK, O_CLOEXEC, O_NONBLOCK, O_RDONLY};
+use libc::{input_event, size_t, ssize_t, ENODEV, EWOULDBLOCK, O_CLOEXEC, O_NONBLOCK, O_RDONLY};
 use log::trace;
 use scopeguard::ScopeGuard;
 use std::collections::HashMap;
@@ -127,7 +127,11 @@ impl Device {
     }
 
     if len < 0 && unsafe { *errno_ptr } != EWOULDBLOCK {
-      return Err(DeviceError::BlockingReadOperation().into());
+      if unsafe { *errno_ptr } == ENODEV {
+        return Err(DeviceError::FailedReadNoSuchDevice.into());
+      }
+
+      return Err(DeviceError::FailedRead(unsafe { *errno_ptr }).into());
     }
 
     Ok(events)
@@ -322,6 +326,9 @@ pub enum DeviceError {
   #[error("no devices found")]
   NoDevicesFound(),
 
-  #[error("read operation can't block device")]
-  BlockingReadOperation(),
+  #[error("read operation failed with code: `{0}`")]
+  FailedRead(i32),
+
+  #[error("read operation failed: ENODEV No such device")]
+  FailedReadNoSuchDevice,
 }
diff --git a/espanso-detect/src/evdev/mod.rs b/espanso-detect/src/evdev/mod.rs
index d7d63377..4f5f5c36 100644
--- a/espanso-detect/src/evdev/mod.rs
+++ b/espanso-detect/src/evdev/mod.rs
@@ -38,8 +38,9 @@ use keymap::Keymap;
 use lazycell::LazyCell;
 use libc::{
   __errno_location, close, epoll_ctl, epoll_event, epoll_wait, EINTR, EPOLLIN, EPOLL_CTL_ADD,
+  EPOLL_CTL_DEL,
 };
-use log::{debug, error, info, trace};
+use log::{debug, error, info, trace, warn};
 use thiserror::Error;
 
 use crate::event::{InputEvent, Key, KeyboardEvent, Variant};
@@ -204,7 +205,9 @@ impl Source for EVDEVSource {
         }
       }
 
-      for ev in evs.iter() {
+      #[allow(clippy::needless_range_loop)]
+      for i in 0usize..(ret as usize) {
+        let ev = evs[i];
         let device = &self.devices[ev.u64 as usize];
         match device.read() {
           Ok(events) if !events.is_empty() => {
@@ -226,7 +229,30 @@ impl Source for EVDEVSource {
             });
           }
           Ok(_) => { /* SKIP EMPTY */ }
-          Err(err) => error!("Can't read from device {}: {}", device.get_path(), err),
+          Err(err) => {
+            if let Some(DeviceError::FailedReadNoSuchDevice) = err.downcast_ref::<DeviceError>() {
+              warn!("Can't read from device {}, this error usually means the device has been disconnected, removing from epoll.", device.get_path());
+
+              if unsafe {
+                epoll_ctl(
+                  *epfd,
+                  EPOLL_CTL_DEL,
+                  device.get_raw_fd(),
+                  std::ptr::null_mut(),
+                )
+              } != 0
+              {
+                error!(
+                  "Could not remove {} from epoll, errno {}",
+                  device.get_path(),
+                  unsafe { *errno_ptr }
+                );
+                return Err(EVDEVSourceError::Internal().into());
+              }
+            } else {
+              error!("Can't read from device {}: {}", device.get_path(), err)
+            }
+          }
         }
       }
     }
diff --git a/espanso-engine/src/event/effect.rs b/espanso-engine/src/event/effect.rs
index 505c0433..48314c17 100644
--- a/espanso-engine/src/event/effect.rs
+++ b/espanso-engine/src/event/effect.rs
@@ -30,7 +30,7 @@ pub struct CursorHintCompensationEvent {
   pub cursor_hint_back_count: usize,
 }
 
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, Default)]
 pub struct TextInjectRequest {
   pub text: String,
   pub force_mode: Option<TextInjectMode>,
diff --git a/espanso-engine/src/event/external.rs b/espanso-engine/src/event/external.rs
new file mode 100644
index 00000000..91ae9d17
--- /dev/null
+++ b/espanso-engine/src/event/external.rs
@@ -0,0 +1,26 @@
+/*
+ * 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 <https://www.gnu.org/licenses/>.
+ */
+
+use std::collections::HashMap;
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct MatchExecRequestEvent {
+  pub trigger: Option<String>,
+  pub args: HashMap<String, String>,
+}
diff --git a/espanso-engine/src/event/mod.rs b/espanso-engine/src/event/mod.rs
index 8769e0ef..20425882 100644
--- a/espanso-engine/src/event/mod.rs
+++ b/espanso-engine/src/event/mod.rs
@@ -18,6 +18,7 @@
  */
 
 pub mod effect;
+pub mod external;
 pub mod input;
 pub mod internal;
 pub mod ui;
@@ -48,7 +49,6 @@ impl Event {
 #[allow(clippy::upper_case_acronyms)]
 pub enum EventType {
   NOOP,
-  ProcessingError(String),
   ExitRequested(ExitMode),
   Exit(ExitMode),
   Heartbeat,
@@ -60,6 +60,9 @@ pub enum EventType {
   TrayIconClicked,
   ContextMenuClicked(input::ContextMenuClickedEvent),
 
+  // External requests
+  MatchExecRequest(external::MatchExecRequestEvent),
+
   // Internal
   MatchesDetected(internal::MatchesDetectedEvent),
   MatchSelected(internal::MatchSelectedEvent),
@@ -73,11 +76,13 @@ pub enum EventType {
   DiscardPrevious(internal::DiscardPreviousEvent),
   DiscardBetween(internal::DiscardBetweenEvent),
   Undo(internal::UndoEvent),
+  RenderingError,
 
   Disabled,
   Enabled,
   DisableRequest,
   EnableRequest,
+  ToggleRequest,
   SecureInputEnabled(internal::SecureInputEnabledEvent),
   SecureInputDisabled,
 
diff --git a/espanso-engine/src/process/default.rs b/espanso-engine/src/process/default.rs
index b0f85b8e..4bb8efdf 100644
--- a/espanso-engine/src/process/default.rs
+++ b/espanso-engine/src/process/default.rs
@@ -33,15 +33,17 @@ use super::{
     render::RenderMiddleware,
   },
   DisableOptions, EnabledStatusProvider, MatchFilter, MatchInfoProvider, MatchProvider,
-  MatchSelector, Matcher, MatcherMiddlewareConfigProvider, Middleware, ModifierStateProvider,
-  Multiplexer, PathProvider, Processor, Renderer, UndoEnabledProvider,
+  MatchResolver, MatchSelector, Matcher, MatcherMiddlewareConfigProvider, Middleware,
+  ModifierStateProvider, Multiplexer, NotificationManager, PathProvider, Processor, Renderer,
+  UndoEnabledProvider,
 };
 use crate::{
   event::{Event, EventType},
   process::middleware::{
     context_menu::ContextMenuMiddleware, disable::DisableMiddleware, exit::ExitMiddleware,
     hotkey::HotKeyMiddleware, icon_status::IconStatusMiddleware,
-    image_resolve::ImageResolverMiddleware, search::SearchMiddleware, suppress::SuppressMiddleware,
+    image_resolve::ImageResolverMiddleware, match_exec::MatchExecRequestMiddleware,
+    notification::NotificationMiddleware, search::SearchMiddleware, suppress::SuppressMiddleware,
     undo::UndoMiddleware,
   },
 };
@@ -70,6 +72,8 @@ impl<'a> DefaultProcessor<'a> {
     undo_enabled_provider: &'a dyn UndoEnabledProvider,
     enabled_status_provider: &'a dyn EnabledStatusProvider,
     modifier_state_provider: &'a dyn ModifierStateProvider,
+    match_resolver: &'a dyn MatchResolver,
+    notification_manager: &'a dyn NotificationManager,
   ) -> DefaultProcessor<'a> {
     Self {
       event_queue: VecDeque::new(),
@@ -82,6 +86,7 @@ impl<'a> DefaultProcessor<'a> {
           matcher_options_provider,
           modifier_state_provider,
         )),
+        Box::new(MatchExecRequestMiddleware::new(match_resolver)),
         Box::new(SuppressMiddleware::new(enabled_status_provider)),
         Box::new(ContextMenuMiddleware::new()),
         Box::new(HotKeyMiddleware::new()),
@@ -103,6 +108,7 @@ impl<'a> DefaultProcessor<'a> {
         )),
         Box::new(SearchMiddleware::new(match_provider)),
         Box::new(MarkdownMiddleware::new()),
+        Box::new(NotificationMiddleware::new(notification_manager)),
         Box::new(DelayForModifierReleaseMiddleware::new(
           modifier_status_provider,
         )),
diff --git a/espanso-engine/src/process/middleware/disable.rs b/espanso-engine/src/process/middleware/disable.rs
index 5b7e633d..41438cfc 100644
--- a/espanso-engine/src/process/middleware/disable.rs
+++ b/espanso-engine/src/process/middleware/disable.rs
@@ -93,6 +93,10 @@ impl Middleware for DisableMiddleware {
         *enabled = false;
         has_status_changed = true;
       }
+      EventType::ToggleRequest => {
+        *enabled = !*enabled;
+        has_status_changed = true;
+      }
       _ => {}
     }
 
diff --git a/espanso-engine/src/process/middleware/markdown.rs b/espanso-engine/src/process/middleware/markdown.rs
index 7deec855..ff1bea25 100644
--- a/espanso-engine/src/process/middleware/markdown.rs
+++ b/espanso-engine/src/process/middleware/markdown.rs
@@ -45,15 +45,8 @@ impl Middleware for MarkdownMiddleware {
       // See also: https://github.com/federico-terzi/espanso/issues/759
       let html = std::panic::catch_unwind(|| markdown::to_html(&m_event.markdown));
       if let Ok(html) = html {
-        let mut html = html.trim();
-
-        // Remove the surrounding paragraph
-        if html.starts_with("<p>") {
-          html = html.trim_start_matches("<p>");
-        }
-        if html.ends_with("</p>") {
-          html = html.trim_end_matches("</p>");
-        }
+        let html = html.trim();
+        let html = remove_paragraph_tag_if_single_occurrence(html);
 
         return Event::caused_by(
           event.source_id,
@@ -72,4 +65,42 @@ impl Middleware for MarkdownMiddleware {
   }
 }
 
-// TODO: test
+// If the match is composed of a single paragraph, we remove the tag to avoid
+// a forced "newline" on some editors. In other words, we assume that if the snippet
+// is composed of a single paragraph, then it should be inlined.
+// On the other hand, if the snippet is composed of multiple paragraphs, then we
+// avoid removing the paragraph to prevent HTML corruption.
+// See: https://github.com/federico-terzi/espanso/issues/811
+fn remove_paragraph_tag_if_single_occurrence(html: &str) -> &str {
+  let paragraph_count = html.matches("<p>").count();
+  if paragraph_count <= 1 {
+    let mut new_html = html;
+    if new_html.starts_with("<p>") {
+      new_html = new_html.trim_start_matches("<p>");
+    }
+    if new_html.ends_with("</p>") {
+      new_html = new_html.trim_end_matches("</p>");
+    }
+
+    new_html
+  } else {
+    html
+  }
+}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+
+  #[test]
+  fn test_remove_paragraph_tag_if_single_occurrence() {
+    assert_eq!(
+      remove_paragraph_tag_if_single_occurrence("<p>single occurrence</p>"),
+      "single occurrence"
+    );
+    assert_eq!(
+      remove_paragraph_tag_if_single_occurrence("<p>multi</p> <p>occurrence</p>"),
+      "<p>multi</p> <p>occurrence</p>"
+    );
+  }
+}
diff --git a/espanso-engine/src/process/middleware/match_exec.rs b/espanso-engine/src/process/middleware/match_exec.rs
new file mode 100644
index 00000000..5b8a6698
--- /dev/null
+++ b/espanso-engine/src/process/middleware/match_exec.rs
@@ -0,0 +1,80 @@
+/*
+ * 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 <https://www.gnu.org/licenses/>.
+ */
+
+use log::warn;
+
+use super::super::Middleware;
+use crate::event::{
+  internal::{DetectedMatch, MatchesDetectedEvent},
+  Event, EventType,
+};
+
+pub trait MatchResolver {
+  fn find_matches_from_trigger(&self, trigger: &str) -> Vec<DetectedMatch>;
+}
+
+pub struct MatchExecRequestMiddleware<'a> {
+  match_resolver: &'a dyn MatchResolver,
+}
+
+impl<'a> MatchExecRequestMiddleware<'a> {
+  pub fn new(match_resolver: &'a dyn MatchResolver) -> Self {
+    Self { match_resolver }
+  }
+}
+
+impl<'a> Middleware for MatchExecRequestMiddleware<'a> {
+  fn name(&self) -> &'static str {
+    "match_exec_request"
+  }
+
+  fn next(&self, event: Event, _: &mut dyn FnMut(Event)) -> Event {
+    if let EventType::MatchExecRequest(m_event) = &event.etype {
+      let mut matches = if let Some(trigger) = &m_event.trigger {
+        self.match_resolver.find_matches_from_trigger(trigger)
+      } else {
+        Vec::new()
+      };
+
+      // Inject the request args into the detected matches
+      matches.iter_mut().for_each(|m| {
+        for (key, value) in &m_event.args {
+          m.args.insert(key.to_string(), value.to_string());
+        }
+      });
+
+      if matches.is_empty() {
+        warn!("received match exec request, but no matches have been found for the given query.");
+        return Event::caused_by(event.source_id, EventType::NOOP);
+      }
+
+      return Event::caused_by(
+        event.source_id,
+        EventType::MatchesDetected(MatchesDetectedEvent {
+          matches,
+          is_search: false,
+        }),
+      );
+    }
+
+    event
+  }
+}
+
+// TODO: test
diff --git a/espanso-engine/src/process/middleware/mod.rs b/espanso-engine/src/process/middleware/mod.rs
index 9ba98b3b..d42e5c51 100644
--- a/espanso-engine/src/process/middleware/mod.rs
+++ b/espanso-engine/src/process/middleware/mod.rs
@@ -29,9 +29,11 @@ pub mod hotkey;
 pub mod icon_status;
 pub mod image_resolve;
 pub mod markdown;
+pub mod match_exec;
 pub mod match_select;
 pub mod matcher;
 pub mod multiplex;
+pub mod notification;
 pub mod render;
 pub mod search;
 pub mod suppress;
diff --git a/espanso-engine/src/process/middleware/notification.rs b/espanso-engine/src/process/middleware/notification.rs
new file mode 100644
index 00000000..e0e6aa9b
--- /dev/null
+++ b/espanso-engine/src/process/middleware/notification.rs
@@ -0,0 +1,57 @@
+/*
+ * 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 <https://www.gnu.org/licenses/>.
+ */
+
+use super::super::Middleware;
+use crate::event::{Event, EventType};
+
+pub trait NotificationManager {
+  fn notify_status_change(&self, enabled: bool);
+  fn notify_rendering_error(&self);
+}
+
+pub struct NotificationMiddleware<'a> {
+  notification_manager: &'a dyn NotificationManager,
+}
+
+impl<'a> NotificationMiddleware<'a> {
+  pub fn new(notification_manager: &'a dyn NotificationManager) -> Self {
+    Self {
+      notification_manager,
+    }
+  }
+}
+
+impl<'a> Middleware for NotificationMiddleware<'a> {
+  fn name(&self) -> &'static str {
+    "notification"
+  }
+
+  fn next(&self, event: Event, _: &mut dyn FnMut(Event)) -> Event {
+    match &event.etype {
+      EventType::Enabled => self.notification_manager.notify_status_change(true),
+      EventType::Disabled => self.notification_manager.notify_status_change(false),
+      EventType::RenderingError => self.notification_manager.notify_rendering_error(),
+      _ => {}
+    }
+
+    event
+  }
+}
+
+// TODO: test
diff --git a/espanso-engine/src/process/middleware/render.rs b/espanso-engine/src/process/middleware/render.rs
index c227c0c6..d8175e0c 100644
--- a/espanso-engine/src/process/middleware/render.rs
+++ b/espanso-engine/src/process/middleware/render.rs
@@ -22,7 +22,7 @@ use std::collections::HashMap;
 use log::error;
 
 use super::super::Middleware;
-use crate::event::{internal::RenderedEvent, Event, EventType};
+use crate::event::{effect::TextInjectRequest, internal::RenderedEvent, Event, EventType};
 use anyhow::Result;
 use thiserror::Error;
 
@@ -62,7 +62,7 @@ impl<'a> Middleware for RenderMiddleware<'a> {
     "render"
   }
 
-  fn next(&self, event: Event, _: &mut dyn FnMut(Event)) -> Event {
+  fn next(&self, event: Event, dispatch: &mut dyn FnMut(Event)) -> Event {
     if let EventType::RenderingRequested(m_event) = event.etype {
       match self.renderer.render(
         m_event.match_id,
@@ -91,7 +91,16 @@ impl<'a> Middleware for RenderMiddleware<'a> {
           }
           _ => {
             error!("error during rendering: {:?}", err);
-            return Event::caused_by(event.source_id, EventType::ProcessingError("An error has occurred during rendering, please examine the logs or contact support.".to_string()));
+
+            dispatch(Event::caused_by(
+                event.source_id,
+                EventType::TextInject(TextInjectRequest {
+                  text: "[Espanso]: An error occurred during rendering, please examine the logs for more information.".to_string(),
+                  ..Default::default()
+                }),
+              ));
+
+            return Event::caused_by(event.source_id, EventType::RenderingError);
           }
         },
       }
diff --git a/espanso-engine/src/process/middleware/undo.rs b/espanso-engine/src/process/middleware/undo.rs
index 29421e28..75bf67d4 100644
--- a/espanso-engine/src/process/middleware/undo.rs
+++ b/espanso-engine/src/process/middleware/undo.rs
@@ -89,14 +89,13 @@ impl<'a> Middleware for UndoMiddleware<'a> {
         }
         *record = None;
       }
-    } else if let EventType::Mouse(_) = &event.etype {
-      // Any mouse event invalidates the undo feature, as it could
-      // represent a change in application
-      *record = None;
-    } else if let EventType::CursorHintCompensation(_) = &event.etype {
-      // Cursor hints invalidate the undo feature, as it would be pretty
-      // complex to determine which delete operations should be performed.
-      // This might change in the future.
+    } else if let EventType::Mouse(_) | EventType::CursorHintCompensation(_) = &event.etype {
+      // Explanation:
+      // * Any mouse event invalidates the undo feature, as it could
+      //   represent a change in application
+      // * Cursor hints invalidate the undo feature, as it would be pretty
+      //   complex to determine which delete operations should be performed.
+      //   This might change in the future.
       *record = None;
     }
 
diff --git a/espanso-engine/src/process/mod.rs b/espanso-engine/src/process/mod.rs
index 1e295f93..f55e5203 100644
--- a/espanso-engine/src/process/mod.rs
+++ b/espanso-engine/src/process/mod.rs
@@ -37,12 +37,14 @@ pub use middleware::action::{EventSequenceProvider, MatchInfoProvider};
 pub use middleware::delay_modifiers::ModifierStatusProvider;
 pub use middleware::disable::DisableOptions;
 pub use middleware::image_resolve::PathProvider;
+pub use middleware::match_exec::MatchResolver;
 pub use middleware::match_select::{MatchFilter, MatchSelector};
 pub use middleware::matcher::{
   MatchResult, Matcher, MatcherEvent, MatcherMiddlewareConfigProvider, ModifierState,
   ModifierStateProvider,
 };
 pub use middleware::multiplex::Multiplexer;
+pub use middleware::notification::NotificationManager;
 pub use middleware::render::{Renderer, RendererError};
 pub use middleware::search::MatchProvider;
 pub use middleware::suppress::EnabledStatusProvider;
@@ -65,6 +67,8 @@ pub fn default<'a, MatcherState>(
   undo_enabled_provider: &'a dyn UndoEnabledProvider,
   enabled_status_provider: &'a dyn EnabledStatusProvider,
   modifier_state_provider: &'a dyn ModifierStateProvider,
+  match_resolver: &'a dyn MatchResolver,
+  notification_manager: &'a dyn NotificationManager,
 ) -> impl Processor + 'a {
   default::DefaultProcessor::new(
     matchers,
@@ -82,5 +86,7 @@ pub fn default<'a, MatcherState>(
     undo_enabled_provider,
     enabled_status_provider,
     modifier_state_provider,
+    match_resolver,
+    notification_manager,
   )
 }
diff --git a/espanso-inject/src/evdev/state.rs b/espanso-inject/src/evdev/state.rs
index 5a962b14..381dd886 100644
--- a/espanso-inject/src/evdev/state.rs
+++ b/espanso-inject/src/evdev/state.rs
@@ -1,7 +1,7 @@
 // This code is a port of the libxkbcommon "interactive-evdev.c" example
 // https://github.com/xkbcommon/libxkbcommon/blob/master/tools/interactive-evdev.c
 
-use std::ffi::CStr;
+use std::{ffi::CStr, os::raw::c_char};
 
 use scopeguard::ScopeGuard;
 
@@ -48,17 +48,17 @@ impl State {
   }
 
   pub fn get_string(&self, code: u32) -> Option<String> {
-    let mut buffer: [u8; 16] = [0; 16];
+    let mut buffer: [c_char; 16] = [0; 16];
     let len = unsafe {
       xkb_state_key_get_utf8(
         self.state,
         code,
-        buffer.as_mut_ptr() as *mut i8,
+        buffer.as_mut_ptr(),
         std::mem::size_of_val(&buffer),
       )
     };
     if len > 0 {
-      let content_raw = unsafe { CStr::from_ptr(buffer.as_ptr() as *mut i8) };
+      let content_raw = unsafe { CStr::from_ptr(buffer.as_ptr()) };
       let string = content_raw.to_string_lossy().to_string();
       if string.is_empty() {
         None
diff --git a/espanso-inject/src/evdev/uinput.rs b/espanso-inject/src/evdev/uinput.rs
index ebacf48c..4445b318 100644
--- a/espanso-inject/src/evdev/uinput.rs
+++ b/espanso-inject/src/evdev/uinput.rs
@@ -23,6 +23,7 @@ use libc::{c_uint, close, ioctl, open, O_NONBLOCK, O_WRONLY};
 use scopeguard::ScopeGuard;
 
 use anyhow::Result;
+use log::error;
 use thiserror::Error;
 
 use super::ffi::{
@@ -39,6 +40,9 @@ impl UInputDevice {
     let uinput_path = CString::new("/dev/uinput").expect("unable to generate /dev/uinput path");
     let raw_fd = unsafe { open(uinput_path.as_ptr(), O_WRONLY | O_NONBLOCK) };
     if raw_fd < 0 {
+      error!("Error: could not open uinput device");
+      error!("This might be due to a recent kernel update, please restart your PC so that the uinput module can be loaded correctly.");
+
       return Err(UInputDeviceError::Open().into());
     }
     let fd = scopeguard::guard(raw_fd, |raw_fd| unsafe {
diff --git a/espanso-modulo/build.rs b/espanso-modulo/build.rs
index 86bc21d7..53a995bc 100644
--- a/espanso-modulo/build.rs
+++ b/espanso-modulo/build.rs
@@ -420,15 +420,26 @@ fn macos_link_search_path() -> Option<String> {
 #[cfg(target_os = "linux")]
 fn build_native() {
   // Make sure wxWidgets is installed
-  if std::process::Command::new("wx-config")
+  // Depending on the installation package, the 'wx-config' command might also be available as 'wx-config-gtk3',
+  // so we need to check for both.
+  // See also: https://github.com/federico-terzi/espanso/issues/840
+  let wx_config_command = if std::process::Command::new("wx-config")
     .arg("--version")
     .output()
-    .is_err()
+    .is_ok()
   {
-    panic!("wxWidgets is not installed, as `wx-config` cannot be exectued")
-  }
+    "wx-config"
+  } else if std::process::Command::new("wx-config-gtk3")
+    .arg("--version")
+    .output()
+    .is_ok()
+  {
+    "wx-config-gtk3"
+  } else {
+    panic!("wxWidgets is not installed, as `wx-config` cannot be executed")
+  };
 
-  let config_path = PathBuf::from("wx-config");
+  let config_path = PathBuf::from(wx_config_command);
   let cpp_flags = get_cpp_flags(&config_path);
 
   let mut build = cc::Build::new();
diff --git a/espanso-modulo/src/sys/form/form.cpp b/espanso-modulo/src/sys/form/form.cpp
index 722cd6be..34813144 100644
--- a/espanso-modulo/src/sys/form/form.cpp
+++ b/espanso-modulo/src/sys/form/form.cpp
@@ -89,11 +89,16 @@ class FormFrame: public wxFrame
     std::vector<void *> fields;
     std::unordered_map<const char *, std::unique_ptr<FieldWrapper>> idMap;
     wxButton *submit;
+    wxStaticText *helpText;
+    bool hasFocusedMultilineControl;
 private:
     void AddComponent(wxPanel *parent, wxBoxSizer *sizer, FieldMetadata meta);
     void Submit();
     void OnSubmitBtn(wxCommandEvent& event);
-    void OnEscape(wxKeyEvent& event);
+    void OnCharHook(wxKeyEvent& event);
+    void UpdateHelpText();
+    void HandleNormalFocus(wxFocusEvent& event);
+    void HandleMultilineFocus(wxFocusEvent& event);
 };
 enum
 {
@@ -113,6 +118,8 @@ bool FormApp::OnInit()
 FormFrame::FormFrame(const wxString& title, const wxPoint& pos, const wxSize& size)
         : wxFrame(NULL, wxID_ANY, title, pos, size, DEFAULT_STYLE)
 {
+    hasFocusedMultilineControl = false;
+
     panel = new wxPanel(this, wxID_ANY);
     wxBoxSizer *vbox = new wxBoxSizer(wxVERTICAL);
     panel->SetSizer(vbox);
@@ -125,8 +132,15 @@ FormFrame::FormFrame(const wxString& title, const wxPoint& pos, const wxSize& si
     submit = new wxButton(panel, ID_Submit, "Submit");
     vbox->Add(submit, 1, wxEXPAND | wxALL, PADDING);
 
+    helpText = new wxStaticText(panel, wxID_ANY, "", wxDefaultPosition, wxDefaultSize);
+    wxFont helpFont = helpText->GetFont();
+    helpFont.SetPointSize(8);
+    helpText->SetFont(helpFont);
+    vbox->Add(helpText, 0, wxLEFT | wxRIGHT | wxBOTTOM, PADDING);
+    UpdateHelpText();
+
     Bind(wxEVT_BUTTON, &FormFrame::OnSubmitBtn, this, ID_Submit);
-    Bind(wxEVT_CHAR_HOOK, &FormFrame::OnEscape, this, wxID_ANY);
+    Bind(wxEVT_CHAR_HOOK, &FormFrame::OnCharHook, this, wxID_ANY);
     // TODO: register ESC click handler: https://forums.wxwidgets.org/viewtopic.php?t=41926
 
     this->SetClientSize(panel->GetBestSize());
@@ -157,6 +171,9 @@ void FormFrame::AddComponent(wxPanel *parent, wxBoxSizer *sizer, FieldMetadata m
             
             if (textMeta->multiline) {
                 textControl->SetMinSize(wxSize(MULTILINE_MIN_WIDTH, MULTILINE_MIN_HEIGHT));
+                textControl->Bind(wxEVT_SET_FOCUS, &FormFrame::HandleMultilineFocus, this, wxID_ANY);
+            } else {
+                textControl->Bind(wxEVT_SET_FOCUS, &FormFrame::HandleNormalFocus, this, wxID_ANY);
             }
 
             // Create the field wrapper
@@ -188,6 +205,8 @@ void FormFrame::AddComponent(wxPanel *parent, wxBoxSizer *sizer, FieldMetadata m
                     ((wxChoice*)choice)->SetSelection(selectedItem);
                 }
 
+                ((wxChoice*)choice)->Bind(wxEVT_SET_FOCUS, &FormFrame::HandleNormalFocus, this, wxID_ANY);
+
                 // Create the field wrapper
                 std::unique_ptr<FieldWrapper> field((FieldWrapper*) new ChoiceFieldWrapper((wxChoice*) choice));
                 idMap[meta.id] = std::move(field);
@@ -197,14 +216,14 @@ void FormFrame::AddComponent(wxPanel *parent, wxBoxSizer *sizer, FieldMetadata m
                 if (selectedItem >= 0) {
                     ((wxListBox*)choice)->SetSelection(selectedItem);
                 }
+
+                ((wxListBox*)choice)->Bind(wxEVT_SET_FOCUS, &FormFrame::HandleNormalFocus, this, wxID_ANY);
                 
                 // Create the field wrapper
                 std::unique_ptr<FieldWrapper> field((FieldWrapper*) new ListFieldWrapper((wxListBox*) choice));
                 idMap[meta.id] = std::move(field);
             }
             
-            
-
             control = choice;
             fields.push_back(choice);
             break;
@@ -253,15 +272,40 @@ void FormFrame::Submit() {
     Close(true);
 }
 
+void FormFrame::HandleNormalFocus(wxFocusEvent& event) {
+    hasFocusedMultilineControl = false;
+    UpdateHelpText();
+    event.Skip();
+}
+
+void FormFrame::HandleMultilineFocus(wxFocusEvent& event) {
+    hasFocusedMultilineControl = true;
+    UpdateHelpText();
+    event.Skip();
+}
+
+void FormFrame::UpdateHelpText() {
+    if (hasFocusedMultilineControl) {
+        helpText->SetLabel("(or press CTRL+Enter to submit, ESC to cancel)");
+    } else {
+        helpText->SetLabel("(or press Enter to submit, ESC to cancel)");
+    }
+    this->SetClientSize(panel->GetBestSize());
+}
+
 void FormFrame::OnSubmitBtn(wxCommandEvent &event) {
     Submit();
 }
 
-void FormFrame::OnEscape(wxKeyEvent& event) {
+void FormFrame::OnCharHook(wxKeyEvent& event) {
     if (event.GetKeyCode() == WXK_ESCAPE) {
         Close(true);
-    }else if(event.GetKeyCode() == WXK_RETURN && wxGetKeyState(WXK_RAW_CONTROL)) {
-        Submit();
+    }else if(event.GetKeyCode() == WXK_RETURN) {
+        if (!hasFocusedMultilineControl || wxGetKeyState(WXK_RAW_CONTROL)) {
+            Submit();
+        } else {
+            event.Skip();
+        }
     }else{
         event.Skip();
     }
diff --git a/espanso-modulo/src/sys/search/search.cpp b/espanso-modulo/src/sys/search/search.cpp
index 0e16d134..a06f7963 100644
--- a/espanso-modulo/src/sys/search/search.cpp
+++ b/espanso-modulo/src/sys/search/search.cpp
@@ -236,6 +236,7 @@ SearchFrame::SearchFrame(const wxString &title, const wxPoint &pos, const wxSize
     vbox->Add(resultBox, 5, wxEXPAND | wxALL, 0);
 
     Bind(wxEVT_CHAR_HOOK, &SearchFrame::OnCharEvent, this, wxID_ANY);
+    searchBar->Bind(wxEVT_CHAR, &SearchFrame::OnCharEvent, this, wxID_ANY);
     Bind(wxEVT_TEXT, &SearchFrame::OnQueryChange, this, textId);
     Bind(wxEVT_LISTBOX_DCLICK, &SearchFrame::OnItemClickEvent, this, resultId);
     Bind(wxEVT_ACTIVATE, &SearchFrame::OnActivate, this, wxID_ANY);
@@ -276,9 +277,9 @@ void SearchFrame::OnCharEvent(wxKeyEvent &event)
             SelectNext();
         }
     }
-    else if (event.GetKeyCode() >= 49 && event.GetKeyCode() <= 56)
+    else if (event.GetUnicodeKey() >= '1' && event.GetUnicodeKey() <= '9')
     { // Alt + num shortcut
-        int index = event.GetKeyCode() - 49;
+        int index = event.GetUnicodeKey() - '1';
         if (wxGetKeyState(WXK_ALT))
         {
             if (resultBox->GetItemCount() > index)
diff --git a/espanso-modulo/src/sys/wizard/wizard.cpp b/espanso-modulo/src/sys/wizard/wizard.cpp
index a4b94cfd..61b965d5 100644
--- a/espanso-modulo/src/sys/wizard/wizard.cpp
+++ b/espanso-modulo/src/sys/wizard/wizard.cpp
@@ -115,6 +115,7 @@ class DerivedFrame : public WizardFrame
 	void add_path_continue_clicked( wxCommandEvent& event );
 	void accessibility_enable_clicked( wxCommandEvent& event );
 	void quit_espanso_clicked( wxCommandEvent& event );
+	void move_bundle_quit_clicked( wxCommandEvent& event );
 
   void navigate_to_next_page_or_close();
   void change_default_button(int target_page);
@@ -373,6 +374,11 @@ void DerivedFrame::change_default_button(int target_page)
   }
 }
 
+void DerivedFrame::move_bundle_quit_clicked( wxCommandEvent& event )
+{
+  Close(true);
+}
+
 bool WizardApp::OnInit()
 {
   wxInitAllImageHandlers();
diff --git a/espanso-modulo/src/sys/wizard/wizard.fbp b/espanso-modulo/src/sys/wizard/wizard.fbp
index 913ec19c..30500df2 100644
--- a/espanso-modulo/src/sys/wizard/wizard.fbp
+++ b/espanso-modulo/src/sys/wizard/wizard.fbp
@@ -708,7 +708,7 @@
                                             <property name="window_extra_style"></property>
                                             <property name="window_name"></property>
                                             <property name="window_style"></property>
-                                            <property name="wrap">-1</property>
+                                            <property name="wrap">500</property>
                                         </object>
                                     </object>
                                     <object class="sizeritem" expanded="1">
@@ -758,7 +758,7 @@
                                             <property name="gripper">0</property>
                                             <property name="hidden">0</property>
                                             <property name="id">wxID_ANY</property>
-                                            <property name="label">Start</property>
+                                            <property name="label">Quit</property>
                                             <property name="margins"></property>
                                             <property name="markup">0</property>
                                             <property name="max_size"></property>
diff --git a/espanso-modulo/src/sys/wizard/wizard_gui.cpp b/espanso-modulo/src/sys/wizard/wizard_gui.cpp
index bb537553..b5bbe8e2 100644
--- a/espanso-modulo/src/sys/wizard/wizard_gui.cpp
+++ b/espanso-modulo/src/sys/wizard/wizard_gui.cpp
@@ -80,13 +80,13 @@ WizardFrame::WizardFrame( wxWindow* parent, wxWindowID id, const wxString& title
 	bSizer22->Add( 0, 20, 0, 0, 5 );
 
 	move_bundle_description = new wxStaticText( move_bundle_panel, wxID_ANY, wxT("Espanso is being run from outside the Applications directory, which prevents it from working correctly.\n\nPlease move the Espanso.app bundle inside your Applications folder and start it again.\n"), wxDefaultPosition, wxDefaultSize, 0 );
-	move_bundle_description->Wrap( -1 );
+	move_bundle_description->Wrap( 500 );
 	bSizer22->Add( move_bundle_description, 0, wxALL, 10 );
 
 
 	bSizer22->Add( 0, 20, 1, wxEXPAND, 5 );
 
-	move_bundle_quit_button = new wxButton( move_bundle_panel, wxID_ANY, wxT("Start"), wxDefaultPosition, wxDefaultSize, 0 );
+	move_bundle_quit_button = new wxButton( move_bundle_panel, wxID_ANY, wxT("Quit"), wxDefaultPosition, wxDefaultSize, 0 );
 
 	move_bundle_quit_button->SetDefault();
 	bSizer22->Add( move_bundle_quit_button, 0, wxALIGN_RIGHT|wxALL, 10 );
diff --git a/espanso/Cargo.toml b/espanso/Cargo.toml
index a31cae3c..a80bffe1 100644
--- a/espanso/Cargo.toml
+++ b/espanso/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "espanso"
-version = "2.0.4-alpha"
+version = "2.0.5-alpha"
 authors = ["Federico Terzi <federicoterzi96@gmail.com>"]
 license = "GPL-3.0"
 description = "Cross-platform Text Expander written in Rust"
diff --git a/espanso/src/cli/cmd.rs b/espanso/src/cli/cmd.rs
new file mode 100644
index 00000000..1dcd4626
--- /dev/null
+++ b/espanso/src/cli/cmd.rs
@@ -0,0 +1,72 @@
+/*
+ * 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 <https://www.gnu.org/licenses/>.
+ */
+
+use std::path::Path;
+
+use crate::{
+  ipc::{create_ipc_client_to_worker, IPCEvent},
+  lock::acquire_worker_lock,
+};
+
+use super::{CliModule, CliModuleArgs};
+use anyhow::{bail, Result};
+use espanso_ipc::IPCClient;
+
+pub fn new() -> CliModule {
+  CliModule {
+    requires_paths: true,
+    subcommand: "cmd".to_string(),
+    entry: cmd_main,
+    ..Default::default()
+  }
+}
+
+fn cmd_main(args: CliModuleArgs) -> i32 {
+  let cli_args = args.cli_args.expect("missing cli_args");
+  let paths = args.paths.expect("missing paths");
+
+  let event = if cli_args.subcommand_matches("enable").is_some() {
+    IPCEvent::EnableRequest
+  } else if cli_args.subcommand_matches("disable").is_some() {
+    IPCEvent::DisableRequest
+  } else if cli_args.subcommand_matches("toggle").is_some() {
+    IPCEvent::ToggleRequest
+  } else if cli_args.subcommand_matches("search").is_some() {
+    IPCEvent::OpenSearchBar
+  } else {
+    eprintln!("unknown command, please run `espanso cmd --help` to see a list of valid ones.");
+    return 1;
+  };
+
+  if let Err(error) = send_event_to_worker(&paths.runtime, event) {
+    eprintln!("unable to send command, error: {:?}", error);
+    return 2;
+  }
+
+  0
+}
+
+fn send_event_to_worker(runtime_path: &Path, event: IPCEvent) -> Result<()> {
+  if acquire_worker_lock(runtime_path).is_some() {
+    bail!("Worker process is not running, please start Espanso first.")
+  }
+
+  let mut client = create_ipc_client_to_worker(runtime_path)?;
+  client.send_async(event)
+}
diff --git a/espanso/src/cli/daemon/mod.rs b/espanso/src/cli/daemon/mod.rs
index 1b1c2fed..206ad738 100644
--- a/espanso/src/cli/daemon/mod.rs
+++ b/espanso/src/cli/daemon/mod.rs
@@ -28,7 +28,7 @@ use espanso_path::Paths;
 use log::{error, info, warn};
 
 use crate::{
-  cli::util::CommandExt,
+  cli::util::{prevent_running_as_root_on_macos, CommandExt},
   common_flags::*,
   exit_code::{
     DAEMON_ALREADY_RUNNING, DAEMON_FATAL_CONFIG_ERROR, DAEMON_GENERAL_ERROR,
@@ -60,6 +60,8 @@ pub fn new() -> CliModule {
 }
 
 fn daemon_main(args: CliModuleArgs) -> i32 {
+  prevent_running_as_root_on_macos();
+
   let paths = args.paths.expect("missing paths in daemon main");
   let paths_overrides = args
     .paths_overrides
diff --git a/espanso/src/cli/launcher/mod.rs b/espanso/src/cli/launcher/mod.rs
index aa77a199..4f40f1e1 100644
--- a/espanso/src/cli/launcher/mod.rs
+++ b/espanso/src/cli/launcher/mod.rs
@@ -72,7 +72,7 @@ fn launcher_main(args: CliModuleArgs) -> i32 {
 
   let is_welcome_page_enabled = !preferences.has_completed_wizard();
 
-  let is_move_bundle_page_enabled = false; // TODO
+  let is_move_bundle_page_enabled = crate::cli::util::is_subject_to_app_translocation_on_macos();
 
   let is_legacy_version_page_enabled = util::is_legacy_version_running(&paths.runtime);
   let runtime_dir_clone = paths.runtime.clone();
diff --git a/espanso/src/cli/match_cli/exec.rs b/espanso/src/cli/match_cli/exec.rs
new file mode 100644
index 00000000..c2d859ed
--- /dev/null
+++ b/espanso/src/cli/match_cli/exec.rs
@@ -0,0 +1,71 @@
+/*
+ * 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 <https://www.gnu.org/licenses/>.
+ */
+
+use std::collections::HashMap;
+
+use anyhow::{bail, Context, Result};
+use clap::ArgMatches;
+use espanso_ipc::IPCClient;
+use espanso_path::Paths;
+
+use crate::{
+  ipc::{create_ipc_client_to_worker, IPCEvent, RequestMatchExpansionPayload},
+  lock::acquire_worker_lock,
+};
+
+pub fn exec_main(cli_args: &ArgMatches, paths: &Paths) -> Result<()> {
+  let trigger = cli_args.value_of("trigger");
+  let args = cli_args.values_of("arg");
+
+  if trigger.is_none() || trigger.map(str::is_empty).unwrap_or(false) {
+    bail!("You need to specify the --trigger 'trigger' option. Run `espanso match exec --help` for more information.");
+  }
+
+  if acquire_worker_lock(&paths.runtime).is_some() {
+    bail!("Worker process is not running, please start Espanso first.")
+  }
+
+  let mut client = create_ipc_client_to_worker(&paths.runtime)?;
+
+  let mut match_args = HashMap::new();
+  if let Some(args) = args {
+    args.for_each(|arg| {
+      let tokens = arg.split_once('=');
+      if let Some((key, value)) = tokens {
+        match_args.insert(key.to_string(), value.to_string());
+      } else {
+        eprintln!(
+          "invalid format for argument '{}', you should follow the 'name=value' format",
+          arg
+        );
+      }
+    })
+  }
+
+  client
+    .send_async(IPCEvent::RequestMatchExpansion(
+      RequestMatchExpansionPayload {
+        trigger: trigger.map(String::from),
+        args: match_args,
+      },
+    ))
+    .context("unable to send payload to worker process")?;
+
+  Ok(())
+}
diff --git a/espanso/src/cli/match_cli/list.rs b/espanso/src/cli/match_cli/list.rs
new file mode 100644
index 00000000..53614de7
--- /dev/null
+++ b/espanso/src/cli/match_cli/list.rs
@@ -0,0 +1,102 @@
+/*
+ * 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 <https://www.gnu.org/licenses/>.
+ */
+
+use anyhow::Result;
+use clap::ArgMatches;
+use espanso_config::{
+  config::{AppProperties, ConfigStore},
+  matches::{store::MatchStore, Match, MatchCause},
+};
+use serde::Serialize;
+
+pub fn list_main(
+  cli_args: &ArgMatches,
+  config_store: Box<dyn ConfigStore>,
+  match_store: Box<dyn MatchStore>,
+) -> Result<()> {
+  let only_triggers = cli_args.is_present("onlytriggers");
+  let preserve_newlines = cli_args.is_present("preservenewlines");
+
+  let class = cli_args.value_of("class");
+  let title = cli_args.value_of("title");
+  let exec = cli_args.value_of("exec");
+
+  let config = config_store.active(&AppProperties { title, class, exec });
+  let match_set = match_store.query(config.match_paths());
+
+  if cli_args.is_present("json") {
+    print_matches_as_json(&match_set.matches)?;
+  } else {
+    print_matches_as_plain(&match_set.matches, only_triggers, preserve_newlines)
+  }
+
+  Ok(())
+}
+
+pub fn print_matches_as_plain(match_list: &[&Match], only_triggers: bool, preserve_newlines: bool) {
+  for m in match_list {
+    let triggers = match &m.cause {
+      MatchCause::None => vec!["(none)".to_string()],
+      MatchCause::Trigger(trigger_cause) => trigger_cause.triggers.clone(),
+      MatchCause::Regex(regex_cause) => vec![regex_cause.regex.clone()],
+    };
+
+    for trigger in triggers {
+      if only_triggers {
+        println!("{}", trigger);
+      } else {
+        let description = m.description();
+
+        if preserve_newlines {
+          println!("{} - {}", trigger, description)
+        } else {
+          println!("{} - {}", trigger, description.replace('\n', " "))
+        }
+      }
+    }
+  }
+}
+
+#[derive(Debug, Serialize)]
+struct JsonMatchEntry {
+  triggers: Vec<String>,
+  replace: String,
+}
+
+pub fn print_matches_as_json(match_list: &[&Match]) -> Result<()> {
+  let mut entries = Vec::new();
+  for m in match_list {
+    let triggers = match &m.cause {
+      MatchCause::None => vec!["(none)".to_string()],
+      MatchCause::Trigger(trigger_cause) => trigger_cause.triggers.clone(),
+      MatchCause::Regex(regex_cause) => vec![regex_cause.regex.clone()],
+    };
+
+    entries.push(JsonMatchEntry {
+      triggers,
+      replace: m.description().to_string(),
+    })
+  }
+
+  let json = serde_json::to_string_pretty(&entries)?;
+
+  println!("{}", json);
+
+  Ok(())
+}
diff --git a/espanso/src/cli/match_cli/mod.rs b/espanso/src/cli/match_cli/mod.rs
new file mode 100644
index 00000000..781bc320
--- /dev/null
+++ b/espanso/src/cli/match_cli/mod.rs
@@ -0,0 +1,57 @@
+/*
+ * 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 <https://www.gnu.org/licenses/>.
+ */
+
+use super::{CliModule, CliModuleArgs};
+
+mod exec;
+mod list;
+
+pub fn new() -> CliModule {
+  CliModule {
+    requires_paths: true,
+    requires_config: true,
+    subcommand: "match".to_string(),
+    entry: match_main,
+    ..Default::default()
+  }
+}
+
+fn match_main(args: CliModuleArgs) -> i32 {
+  let cli_args = args.cli_args.expect("missing cli_args");
+  let config_store = args.config_store.expect("missing config_store");
+  let match_store = args.match_store.expect("missing match_store");
+  let paths = args.paths.expect("missing paths");
+
+  if let Some(sub_args) = cli_args.subcommand_matches("list") {
+    if let Err(err) = list::list_main(sub_args, config_store, match_store) {
+      eprintln!("unable to list matches: {:?}", err);
+      return 1;
+    }
+  } else if let Some(sub_args) = cli_args.subcommand_matches("exec") {
+    if let Err(err) = exec::exec_main(sub_args, &paths) {
+      eprintln!("unable to exec match: {:?}", err);
+      return 1;
+    }
+  } else {
+    eprintln!("Invalid use, please run 'espanso match --help' to get more information.");
+    return 1;
+  }
+
+  0
+}
diff --git a/espanso/src/cli/mod.rs b/espanso/src/cli/mod.rs
index 2cdba944..f7a37684 100644
--- a/espanso/src/cli/mod.rs
+++ b/espanso/src/cli/mod.rs
@@ -23,11 +23,13 @@ use clap::ArgMatches;
 use espanso_config::{config::ConfigStore, error::NonFatalErrorSet, matches::store::MatchStore};
 use espanso_path::Paths;
 
+pub mod cmd;
 pub mod daemon;
 pub mod edit;
 pub mod env_path;
 pub mod launcher;
 pub mod log;
+pub mod match_cli;
 pub mod migrate;
 pub mod modulo;
 pub mod package;
diff --git a/espanso/src/cli/service/macos.rs b/espanso/src/cli/service/macos.rs
index f6914f5a..5529c154 100644
--- a/espanso/src/cli/service/macos.rs
+++ b/espanso/src/cli/service/macos.rs
@@ -17,18 +17,31 @@
  * along with espanso.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-use anyhow::Result;
-use log::{info, warn};
+use anyhow::{bail, Result};
+use log::{error, info, warn};
 use std::process::Command;
 use std::{fs::create_dir_all, process::ExitStatus};
 use thiserror::Error;
 
+use crate::cli::util::prevent_running_as_root_on_macos;
+use crate::error_eprintln;
+
 #[cfg(target_os = "macos")]
 const SERVICE_PLIST_CONTENT: &str = include_str!("../../res/macos/com.federicoterzi.espanso.plist");
 #[cfg(target_os = "macos")]
 const SERVICE_PLIST_FILE_NAME: &str = "com.federicoterzi.espanso.plist";
 
 pub fn register() -> Result<()> {
+  prevent_running_as_root_on_macos();
+
+  if crate::cli::util::is_subject_to_app_translocation_on_macos() {
+    error_eprintln!("Unable to register Espanso as service, please move the Espanso.app bundle inside the /Applications directory to proceed.");
+    error_eprintln!(
+      "For more information, please see: https://github.com/federico-terzi/espanso/issues/844"
+    );
+    bail!("macOS activated app-translocation on Espanso");
+  }
+
   let home_dir = dirs::home_dir().expect("could not get user home directory");
   let library_dir = home_dir.join("Library");
   let agents_dir = library_dir.join("LaunchAgents");
@@ -94,6 +107,8 @@ pub enum RegisterError {
 }
 
 pub fn unregister() -> Result<()> {
+  prevent_running_as_root_on_macos();
+
   let home_dir = dirs::home_dir().expect("could not get user home directory");
   let library_dir = home_dir.join("Library");
   let agents_dir = library_dir.join("LaunchAgents");
diff --git a/espanso/src/cli/service/mod.rs b/espanso/src/cli/service/mod.rs
index c80e4186..6cf18fa1 100644
--- a/espanso/src/cli/service/mod.rs
+++ b/espanso/src/cli/service/mod.rs
@@ -17,12 +17,14 @@
  * along with espanso.  If not, see <https://www.gnu.org/licenses/>.
  */
 
+use std::time::Instant;
+
 use super::{CliModule, CliModuleArgs, PathsOverrides};
 use crate::{
   error_eprintln,
   exit_code::{
     SERVICE_ALREADY_RUNNING, SERVICE_FAILURE, SERVICE_NOT_REGISTERED, SERVICE_NOT_RUNNING,
-    SERVICE_SUCCESS,
+    SERVICE_SUCCESS, SERVICE_TIMED_OUT,
   },
   info_println,
   lock::acquire_worker_lock,
@@ -99,6 +101,7 @@ fn service_main(args: CliModuleArgs) -> i32 {
     return status_main(&paths);
   } else if let Some(sub_args) = cli_args.subcommand_matches("restart") {
     stop_main(&paths);
+    std::thread::sleep(std::time::Duration::from_millis(300));
     return start_main(&paths, &paths_overrides, sub_args);
   }
 
@@ -131,12 +134,23 @@ fn start_main(paths: &Paths, _paths_overrides: &PathsOverrides, args: &ArgMatche
     if let Err(err) = start_service() {
       error_eprintln!("unable to start service: {}", err);
       return SERVICE_FAILURE;
-    } else {
+    }
+  }
+
+  let now = Instant::now();
+  while now.elapsed() < std::time::Duration::from_secs(5) {
+    let lock_file = acquire_worker_lock(&paths.runtime);
+    if lock_file.is_none() {
       info_println!("espanso started correctly!");
+      return SERVICE_SUCCESS;
     }
+    drop(lock_file);
+
+    std::thread::sleep(std::time::Duration::from_millis(200));
   }
 
-  SERVICE_SUCCESS
+  error_eprintln!("unable to start service: timed out");
+  SERVICE_TIMED_OUT
 }
 
 fn stop_main(paths: &Paths) -> i32 {
diff --git a/espanso/src/cli/util.rs b/espanso/src/cli/util.rs
index e029a745..335009ac 100644
--- a/espanso/src/cli/util.rs
+++ b/espanso/src/cli/util.rs
@@ -53,3 +53,33 @@ impl CommandExt for Command {
     self
   }
 }
+
+// For context, see also this issue: https://github.com/federico-terzi/espanso/issues/648
+#[cfg(target_os = "macos")]
+pub fn prevent_running_as_root_on_macos() {
+  use crate::{error_eprintln, exit_code::UNEXPECTED_RUN_AS_ROOT};
+
+  if unsafe { libc::geteuid() } == 0 {
+    error_eprintln!("Espanso is being run as root, but this can create unwanted side-effects. Please run it as a normal user.");
+    std::process::exit(UNEXPECTED_RUN_AS_ROOT);
+  }
+}
+
+#[cfg(not(target_os = "macos"))]
+pub fn prevent_running_as_root_on_macos() {
+  // Do nothing on other platforms
+}
+
+// This is needed to make sure the app is NOT subject to "App Translocation" on
+// macOS, which would make Espanso misbehave on some circumstances.
+// For more information, see: https://github.com/federico-terzi/espanso/issues/844
+pub fn is_subject_to_app_translocation_on_macos() -> bool {
+  if !cfg!(target_os = "macos") {
+    return false;
+  }
+
+  let exec_path = std::env::current_exe().expect("unable to extract executable path");
+  let exec_path = exec_path.to_string_lossy();
+
+  exec_path.contains("/private/")
+}
diff --git a/espanso/src/cli/worker/engine/funnel/ipc.rs b/espanso/src/cli/worker/engine/funnel/ipc.rs
new file mode 100644
index 00000000..0708358f
--- /dev/null
+++ b/espanso/src/cli/worker/engine/funnel/ipc.rs
@@ -0,0 +1,79 @@
+/*
+ * 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 <https://www.gnu.org/licenses/>.
+ */
+
+use crossbeam::channel::{Receiver, Select, SelectedOperation};
+
+use espanso_engine::{
+  event::{Event, EventType},
+  funnel,
+};
+use log::warn;
+
+use super::sequencer::Sequencer;
+
+pub struct IpcEventSource<'a> {
+  pub ipc_event_receiver: Receiver<EventType>,
+  pub sequencer: &'a Sequencer,
+}
+
+impl<'a> IpcEventSource<'a> {
+  pub fn new(ipc_event_receiver: Receiver<EventType>, sequencer: &'a Sequencer) -> Self {
+    IpcEventSource {
+      ipc_event_receiver,
+      sequencer,
+    }
+  }
+}
+
+impl<'a> funnel::Source<'a> for IpcEventSource<'a> {
+  fn register(&'a self, select: &mut Select<'a>) -> usize {
+    select.recv(&self.ipc_event_receiver)
+  }
+
+  fn receive(&self, op: SelectedOperation) -> Option<Event> {
+    let ipc_event = op
+      .recv(&self.ipc_event_receiver)
+      .expect("unable to select data from IpcEventSource receiver");
+
+    // Execute only events that have been whitelisted
+    if !is_event_type_allowed(&ipc_event) {
+      warn!(
+        "received black-listed event from IPC stream, blocking it: {:?}",
+        ipc_event
+      );
+      return None;
+    }
+
+    Some(Event {
+      source_id: self.sequencer.next_id(),
+      etype: ipc_event,
+    })
+  }
+}
+
+fn is_event_type_allowed(event: &EventType) -> bool {
+  matches!(
+    event,
+    EventType::MatchExecRequest(_)
+      | EventType::ShowSearchBar
+      | EventType::DisableRequest
+      | EventType::EnableRequest
+      | EventType::ToggleRequest
+  )
+}
diff --git a/espanso/src/cli/worker/engine/funnel/mod.rs b/espanso/src/cli/worker/engine/funnel/mod.rs
index f1d699b8..c0706fc0 100644
--- a/espanso/src/cli/worker/engine/funnel/mod.rs
+++ b/espanso/src/cli/worker/engine/funnel/mod.rs
@@ -35,6 +35,7 @@ use self::{
 
 pub mod detect;
 pub mod exit;
+pub mod ipc;
 pub mod key_state;
 pub mod modifier;
 pub mod secure_input;
diff --git a/espanso/src/cli/worker/engine/funnel/secure_input.rs b/espanso/src/cli/worker/engine/funnel/secure_input.rs
index 3e4da03e..ad9437de 100644
--- a/espanso/src/cli/worker/engine/funnel/secure_input.rs
+++ b/espanso/src/cli/worker/engine/funnel/secure_input.rs
@@ -43,33 +43,22 @@ impl<'a> SecureInputSource<'a> {
 
 impl<'a> funnel::Source<'a> for SecureInputSource<'a> {
   fn register(&'a self, select: &mut Select<'a>) -> usize {
-    if cfg!(target_os = "macos") {
-      select.recv(&self.receiver)
-    } else {
-      999999
-    }
+    select.recv(&self.receiver)
   }
 
   fn receive(&self, op: SelectedOperation) -> Option<Event> {
-    if cfg!(target_os = "macos") {
-      let si_event = op
-        .recv(&self.receiver)
-        .expect("unable to select data from SecureInputSource receiver");
+    let si_event = op
+      .recv(&self.receiver)
+      .expect("unable to select data from SecureInputSource receiver");
 
-      Some(Event {
-        source_id: self.sequencer.next_id(),
-        etype: match si_event {
-          SecureInputEvent::Disabled => EventType::SecureInputDisabled,
-          SecureInputEvent::Enabled { app_name, app_path } => {
-            EventType::SecureInputEnabled(SecureInputEnabledEvent { app_name, app_path })
-          }
-        },
-      })
-    } else {
-      Some(Event {
-        source_id: self.sequencer.next_id(),
-        etype: EventType::NOOP,
-      })
-    }
+    Some(Event {
+      source_id: self.sequencer.next_id(),
+      etype: match si_event {
+        SecureInputEvent::Disabled => EventType::SecureInputDisabled,
+        SecureInputEvent::Enabled { app_name, app_path } => {
+          EventType::SecureInputEnabled(SecureInputEnabledEvent { app_name, app_path })
+        }
+      },
+    })
   }
 }
diff --git a/espanso/src/cli/worker/engine/mod.rs b/espanso/src/cli/worker/engine/mod.rs
index f68309ad..07c653ab 100644
--- a/espanso/src/cli/worker/engine/mod.rs
+++ b/espanso/src/cli/worker/engine/mod.rs
@@ -23,7 +23,7 @@ use anyhow::Result;
 use crossbeam::channel::Receiver;
 use espanso_config::{config::ConfigStore, matches::store::MatchStore};
 use espanso_detect::SourceCreationOptions;
-use espanso_engine::event::ExitMode;
+use espanso_engine::event::{EventType, ExitMode};
 use espanso_inject::{InjectorCreationOptions, KeyboardStateProvider};
 use espanso_path::Paths;
 use espanso_ui::{event::UIEvent, UIRemote};
@@ -82,6 +82,7 @@ pub fn initialize_and_spawn(
   secure_input_receiver: Receiver<SecureInputEvent>,
   use_evdev_backend: bool,
   start_reason: Option<String>,
+  ipc_event_receiver: Receiver<EventType>,
 ) -> Result<JoinHandle<ExitMode>> {
   let handle = std::thread::Builder::new()
     .name("engine thread".to_string())
@@ -131,17 +132,18 @@ pub fn initialize_and_spawn(
         })
         .expect("failed to initialize detector module");
       let exit_source = super::engine::funnel::exit::ExitSource::new(exit_signal, &sequencer);
+      let ipc_event_source =
+        super::engine::funnel::ipc::IpcEventSource::new(ipc_event_receiver, &sequencer);
       let ui_source = super::engine::funnel::ui::UISource::new(ui_event_receiver, &sequencer);
       let secure_input_source = super::engine::funnel::secure_input::SecureInputSource::new(
         secure_input_receiver,
         &sequencer,
       );
-      let sources: Vec<&dyn espanso_engine::funnel::Source> = vec![
-        &detect_source,
-        &exit_source,
-        &ui_source,
-        &secure_input_source,
-      ];
+      let mut sources: Vec<&dyn espanso_engine::funnel::Source> =
+        vec![&detect_source, &exit_source, &ui_source, &ipc_event_source];
+      if cfg!(target_os = "macos") {
+        sources.push(&secure_input_source);
+      }
       let funnel = espanso_engine::funnel::default(&sources);
 
       let rolling_matcher = RollingMatcherAdapter::new(
@@ -210,6 +212,8 @@ pub fn initialize_and_spawn(
       let disable_options =
         process::middleware::disable::extract_disable_options(&*config_manager.default());
 
+      let notification_manager = NotificationManager::new(&*ui_remote, default_config);
+
       let mut processor = espanso_engine::process::default(
         &matchers,
         &config_manager,
@@ -226,6 +230,8 @@ pub fn initialize_and_spawn(
         &config_manager,
         &config_manager,
         &modifier_state_store,
+        &combined_match_cache,
+        &notification_manager,
       );
 
       let event_injector = EventInjectorAdapter::new(&*injector, &config_manager);
@@ -254,8 +260,6 @@ pub fn initialize_and_spawn(
         }
       }
 
-      let notification_manager = NotificationManager::new(&*ui_remote, default_config);
-
       match start_reason.as_deref() {
         Some(flag) if flag == WORKER_START_REASON_CONFIG_CHANGED => {
           notification_manager.notify_config_reloaded(false);
diff --git a/espanso/src/cli/worker/ipc.rs b/espanso/src/cli/worker/ipc.rs
index 07f61774..1ae545e4 100644
--- a/espanso/src/cli/worker/ipc.rs
+++ b/espanso/src/cli/worker/ipc.rs
@@ -21,13 +21,17 @@ use std::path::Path;
 
 use anyhow::Result;
 use crossbeam::channel::Sender;
-use espanso_engine::event::ExitMode;
+use espanso_engine::event::{external::MatchExecRequestEvent, EventType, ExitMode};
 use espanso_ipc::{EventHandlerResponse, IPCServer};
 use log::{error, warn};
 
 use crate::ipc::IPCEvent;
 
-pub fn initialize_and_spawn(runtime_dir: &Path, exit_notify: Sender<ExitMode>) -> Result<()> {
+pub fn initialize_and_spawn(
+  runtime_dir: &Path,
+  exit_notify: Sender<ExitMode>,
+  event_notify: Sender<EventType>,
+) -> Result<()> {
   let server = crate::ipc::create_worker_ipc_server(runtime_dir)?;
 
   std::thread::Builder::new()
@@ -55,6 +59,17 @@ pub fn initialize_and_spawn(runtime_dir: &Path, exit_notify: Sender<ExitMode>) -
 
             EventHandlerResponse::NoResponse
           }
+          IPCEvent::DisableRequest => send_event(&event_notify, EventType::DisableRequest),
+          IPCEvent::EnableRequest => send_event(&event_notify, EventType::EnableRequest),
+          IPCEvent::ToggleRequest => send_event(&event_notify, EventType::ToggleRequest),
+          IPCEvent::OpenSearchBar => send_event(&event_notify, EventType::ShowSearchBar),
+          IPCEvent::RequestMatchExpansion(payload) => send_event(
+            &event_notify,
+            EventType::MatchExecRequest(MatchExecRequestEvent {
+              trigger: payload.trigger,
+              args: payload.args,
+            }),
+          ),
           #[allow(unreachable_patterns)]
           unexpected_event => {
             warn!(
@@ -70,3 +85,17 @@ pub fn initialize_and_spawn(runtime_dir: &Path, exit_notify: Sender<ExitMode>) -
 
   Ok(())
 }
+
+fn send_event(
+  event_notify: &Sender<EventType>,
+  event: EventType,
+) -> EventHandlerResponse<IPCEvent> {
+  if let Err(err) = event_notify.send(event) {
+    error!(
+      "experienced error while sending event signal from worker ipc handler: {}",
+      err
+    );
+  }
+
+  EventHandlerResponse::NoResponse
+}
diff --git a/espanso/src/cli/worker/match_cache.rs b/espanso/src/cli/worker/match_cache.rs
index fe23b7d1..6c2423f8 100644
--- a/espanso/src/cli/worker/match_cache.rs
+++ b/espanso/src/cli/worker/match_cache.rs
@@ -21,8 +21,9 @@ use std::collections::HashMap;
 
 use espanso_config::{
   config::ConfigStore,
-  matches::{store::MatchStore, Match, MatchEffect},
+  matches::{store::MatchStore, Match, MatchCause, MatchEffect},
 };
+use espanso_engine::event::internal::DetectedMatch;
 
 use super::{builtin::BuiltInMatch, engine::process::middleware::match_select::MatchSummary};
 
@@ -164,3 +165,50 @@ impl<'a> espanso_engine::process::MatchProvider for CombinedMatchCache<'a> {
     ids
   }
 }
+
+impl<'a> espanso_engine::process::MatchResolver for CombinedMatchCache<'a> {
+  fn find_matches_from_trigger(&self, trigger: &str) -> Vec<DetectedMatch> {
+    let user_matches: Vec<DetectedMatch> = self
+      .user_match_cache
+      .cache
+      .values()
+      .filter_map(|m| {
+        if let MatchCause::Trigger(trigger_cause) = &m.cause {
+          if trigger_cause.triggers.iter().any(|t| t == trigger) {
+            Some(DetectedMatch {
+              id: m.id,
+              trigger: Some(trigger.to_string()),
+              ..Default::default()
+            })
+          } else {
+            None
+          }
+        } else {
+          None
+        }
+      })
+      .collect();
+
+    let builtin_matches: Vec<DetectedMatch> = self
+      .builtin_match_cache
+      .values()
+      .filter_map(|m| {
+        if m.triggers.iter().any(|t| t == trigger) {
+          Some(DetectedMatch {
+            id: m.id,
+            trigger: Some(trigger.to_string()),
+            ..Default::default()
+          })
+        } else {
+          None
+        }
+      })
+      .collect();
+
+    let mut matches = Vec::with_capacity(user_matches.len() + builtin_matches.len());
+    matches.extend(user_matches);
+    matches.extend(builtin_matches);
+
+    matches
+  }
+}
diff --git a/espanso/src/cli/worker/mod.rs b/espanso/src/cli/worker/mod.rs
index 473b5f87..b2cf019c 100644
--- a/espanso/src/cli/worker/mod.rs
+++ b/espanso/src/cli/worker/mod.rs
@@ -22,6 +22,7 @@ use espanso_engine::event::ExitMode;
 use log::{debug, error, info};
 
 use crate::{
+  cli::util::prevent_running_as_root_on_macos,
   exit_code::{
     WORKER_ALREADY_RUNNING, WORKER_EXIT_ALL_PROCESSES, WORKER_GENERAL_ERROR,
     WORKER_LEGACY_ALREADY_RUNNING, WORKER_RESTART, WORKER_SUCCESS,
@@ -58,6 +59,8 @@ pub fn new() -> CliModule {
 }
 
 fn worker_main(args: CliModuleArgs) -> i32 {
+  prevent_running_as_root_on_macos();
+
   let paths = args.paths.expect("missing paths in worker main");
   let cli_args = args.cli_args.expect("missing cli_args in worker main");
 
@@ -112,6 +115,7 @@ fn worker_main(args: CliModuleArgs) -> i32 {
     .expect("unable to initialize UI module");
 
   let (engine_exit_notify, engine_exit_receiver) = unbounded();
+  let (ipc_event_notify, ipc_event_receiver) = unbounded();
   let (engine_ui_event_sender, engine_ui_event_receiver) = unbounded();
   let (engine_secure_input_sender, engine_secure_input_receiver) = unbounded();
 
@@ -126,11 +130,12 @@ fn worker_main(args: CliModuleArgs) -> i32 {
     engine_secure_input_receiver,
     use_evdev_backend,
     start_reason,
+    ipc_event_receiver,
   )
   .expect("unable to initialize engine");
 
   // Setup the IPC server
-  ipc::initialize_and_spawn(&paths.runtime, engine_exit_notify.clone())
+  ipc::initialize_and_spawn(&paths.runtime, engine_exit_notify.clone(), ipc_event_notify)
     .expect("unable to initialize IPC server");
 
   // If specified, automatically monitor the daemon status and
diff --git a/espanso/src/cli/worker/ui/notification.rs b/espanso/src/cli/worker/ui/notification.rs
index c08f139f..9d18479e 100644
--- a/espanso/src/cli/worker/ui/notification.rs
+++ b/espanso/src/cli/worker/ui/notification.rs
@@ -54,3 +54,23 @@ impl<'a> NotificationManager<'a> {
     self.notify("Updated keyboard layout!");
   }
 }
+
+impl<'a> espanso_engine::process::NotificationManager for NotificationManager<'a> {
+  fn notify_status_change(&self, enabled: bool) {
+    // Don't notify the status change outside Linux for now
+    if !cfg!(target_os = "linux") {
+      return;
+    }
+
+    if enabled {
+      self.notify("Espanso enabled!")
+    } else {
+      self.notify("Espanso disabled!")
+    }
+  }
+
+  fn notify_rendering_error(&self) {
+    self
+      .notify("An error occurred during rendering, please examine the logs for more information.");
+  }
+}
diff --git a/espanso/src/exit_code.rs b/espanso/src/exit_code.rs
index 01ade17a..c3bd78ac 100644
--- a/espanso/src/exit_code.rs
+++ b/espanso/src/exit_code.rs
@@ -51,6 +51,7 @@ pub const SERVICE_FAILURE: i32 = 1;
 pub const SERVICE_NOT_REGISTERED: i32 = 2;
 pub const SERVICE_ALREADY_RUNNING: i32 = 3;
 pub const SERVICE_NOT_RUNNING: i32 = 4;
+pub const SERVICE_TIMED_OUT: i32 = 5;
 
 pub const WORKAROUND_SUCCESS: i32 = 0;
 #[allow(dead_code)]
@@ -68,6 +69,9 @@ pub const PACKAGE_LIST_FAILED: i32 = 4;
 pub const PACKAGE_UPDATE_FAILED: i32 = 5;
 pub const PACKAGE_UPDATE_PARTIAL_FAILURE: i32 = 6;
 
+#[allow(dead_code)]
+pub const UNEXPECTED_RUN_AS_ROOT: i32 = 42;
+
 use std::sync::Mutex;
 
 use crate::error_eprintln;
diff --git a/espanso/src/ipc.rs b/espanso/src/ipc.rs
index 4d157d4c..5e8d3b4e 100644
--- a/espanso/src/ipc.rs
+++ b/espanso/src/ipc.rs
@@ -20,12 +20,25 @@
 use anyhow::Result;
 use espanso_ipc::{IPCClient, IPCServer};
 use serde::{Deserialize, Serialize};
-use std::path::Path;
+use std::{collections::HashMap, path::Path};
 
 #[derive(Debug, Serialize, Deserialize)]
 pub enum IPCEvent {
   Exit,
   ExitAllProcesses,
+
+  EnableRequest,
+  DisableRequest,
+  ToggleRequest,
+  OpenSearchBar,
+
+  RequestMatchExpansion(RequestMatchExpansionPayload),
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct RequestMatchExpansionPayload {
+  pub trigger: Option<String>,
+  pub args: HashMap<String, String>,
 }
 
 pub fn create_daemon_ipc_server(runtime_dir: &Path) -> Result<impl IPCServer<IPCEvent>> {
diff --git a/espanso/src/main.rs b/espanso/src/main.rs
index d0897ed5..58cf84fa 100644
--- a/espanso/src/main.rs
+++ b/espanso/src/main.rs
@@ -71,6 +71,8 @@ lazy_static! {
     cli::service::new(),
     cli::workaround::new(),
     cli::package::new(),
+    cli::match_cli::new(),
+    cli::cmd::new(),
   ];
   static ref ALIASES: Vec<CliAlias> = vec![
     CliAlias {
@@ -219,17 +221,17 @@ fn main() {
         .subcommand(SubCommand::with_name("unregister").about("Remove 'espanso' command from PATH"))
         .about("Add or remove the 'espanso' command from the PATH"),
     )
-    // .subcommand(SubCommand::with_name("cmd")
-    //     .about("Send a command to the espanso daemon.")
-    //     .subcommand(SubCommand::with_name("exit")
-    //         .about("Terminate the daemon."))
-    //     .subcommand(SubCommand::with_name("enable")
-    //         .about("Enable the espanso replacement engine."))
-    //     .subcommand(SubCommand::with_name("disable")
-    //         .about("Disable the espanso replacement engine."))
-    //     .subcommand(SubCommand::with_name("toggle")
-    //         .about("Toggle the status of the espanso replacement engine."))
-    // )
+    .subcommand(SubCommand::with_name("cmd")
+        .about("Send a command to the espanso daemon.")
+        .subcommand(SubCommand::with_name("enable")
+            .about("Enable expansions."))
+        .subcommand(SubCommand::with_name("disable")
+            .about("Disable expansions."))
+        .subcommand(SubCommand::with_name("toggle")
+            .about("Enable/Disable expansions."))
+        .subcommand(SubCommand::with_name("search")
+            .about("Open the Espanso's search bar."))
+    )
     .subcommand(SubCommand::with_name("edit")
         .about("Shortcut to open the default text editor to edit config files")
         .arg(Arg::with_name("target_file")
@@ -251,10 +253,6 @@ For example, specifying 'email' is equivalent to 'match/email.yml'."#))
         .setting(AppSettings::Hidden)
         .about("Start the daemon without spawning a new process."),
     )
-    // .subcommand(SubCommand::with_name("register")
-    //     .about("MacOS and Linux only. Register espanso in the system daemon manager."))
-    // .subcommand(SubCommand::with_name("unregister")
-    //     .about("MacOS and Linux only. Unregister espanso from the system daemon manager."))
     .subcommand(SubCommand::with_name("launcher").setting(AppSettings::Hidden))
     .subcommand(SubCommand::with_name("log").about("Print the daemon logs."))
     .subcommand(
@@ -305,8 +303,6 @@ For example, specifying 'email' is equivalent to 'match/email.yml'."#))
             ),
         ),
     )
-    // .subcommand(SubCommand::with_name("status")
-    //     .about("Check if the espanso daemon is running or not."))
     .subcommand(
       SubCommand::with_name("path")
         .about("Prints all the espanso directory paths to easily locate configuration and matches.")
@@ -353,39 +349,69 @@ For example, specifying 'email' is equivalent to 'match/email.yml'."#))
     .subcommand(restart_subcommand)
     .subcommand(stop_subcommand)
     .subcommand(status_subcommand)
-    // .subcommand(SubCommand::with_name("match")
-    //     .about("List and execute matches from the CLI")
-    //     .subcommand(SubCommand::with_name("list")
-    //         .about("Print all matches to standard output")
-    //         .arg(Arg::with_name("json")
-    //             .short("j")
-    //             .long("json")
-    //             .help("Return the matches as json")
-    //             .required(false)
-    //             .takes_value(false)
-    //         )
-    //         .arg(Arg::with_name("onlytriggers")
-    //             .short("t")
-    //             .long("onlytriggers")
-    //             .help("Print only triggers without replacement")
-    //             .required(false)
-    //             .takes_value(false)
-    //         )
-    //         .arg(Arg::with_name("preservenewlines")
-    //             .short("n")
-    //             .long("preservenewlines")
-    //             .help("Preserve newlines when printing replacements")
-    //             .required(false)
-    //             .takes_value(false)
-    //         )
-    //     )
-    //     .subcommand(SubCommand::with_name("exec")
-    //         .about("Triggers the expansion of the given match")
-    //         .arg(Arg::with_name("trigger")
-    //             .help("The trigger of the match to be expanded")
-    //         )
-    //     )
-    // )
+    .subcommand(SubCommand::with_name("match")
+        .about("List and execute matches from the CLI")
+        .subcommand(SubCommand::with_name("list")
+            .about("Print matches to standard output")
+            .arg(Arg::with_name("json")
+                .short("j")
+                .long("json")
+                .help("Output matches to the JSON format")
+                .required(false)
+                .takes_value(false)
+            )
+            .arg(Arg::with_name("onlytriggers")
+                .short("t")
+                .long("only-triggers")
+                .help("Print only triggers without replacement")
+                .required(false)
+                .takes_value(false)
+            )
+            .arg(Arg::with_name("preservenewlines")
+                .short("n")
+                .long("preserve-newlines")
+                .help("Preserve newlines when printing replacements. Does nothing when using JSON format.")
+                .required(false)
+                .takes_value(false)
+            )
+            .arg(Arg::with_name("class")
+                .long("class")
+                .help("Only return matches that would be active with the given class. This is relevant if you want to list matches only active inside an app-specific config.")
+                .required(false)
+                .takes_value(true)
+            )
+            .arg(Arg::with_name("title")
+                .long("title")
+                .help("Only return matches that would be active with the given title. This is relevant if you want to list matches only active inside an app-specific config.")
+                .required(false)
+                .takes_value(true)
+            )
+            .arg(Arg::with_name("exec")
+                .long("exec")
+                .help("Only return matches that would be active with the given exec. This is relevant if you want to list matches only active inside an app-specific config.")
+                .required(false)
+                .takes_value(true)
+            )
+        )
+        .subcommand(SubCommand::with_name("exec")
+            .about("Triggers the expansion of a match")
+            .arg(Arg::with_name("trigger")
+                .short("t")
+                .long("trigger")
+                .help("The trigger of the match to be expanded")
+                .required(false)
+                .takes_value(true)
+            )
+            .arg(Arg::with_name("arg")
+                .long("arg")
+                .help("Specify also an argument for the expansion, following the --arg 'name=value' format. You can specify multiple ones.")
+                .required(false)
+                .takes_value(true)
+                .multiple(true)
+                .number_of_values(1)
+            )
+        )
+    )
     .subcommand(
       SubCommand::with_name("package")
         .about("package-management commands")
diff --git a/espanso/src/path/macos.rs b/espanso/src/path/macos.rs
index 58f020ae..cd0f7c21 100644
--- a/espanso/src/path/macos.rs
+++ b/espanso/src/path/macos.rs
@@ -17,11 +17,11 @@
  * along with espanso.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-use std::io::ErrorKind;
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
+use std::{fs::create_dir_all, io::ErrorKind};
 use thiserror::Error;
 
-use anyhow::Result;
+use anyhow::{bail, Context, Result};
 use log::{error, warn};
 
 pub fn is_espanso_in_path() -> bool {
@@ -29,39 +29,49 @@ pub fn is_espanso_in_path() -> bool {
 }
 
 pub fn add_espanso_to_path(prompt_when_necessary: bool) -> Result<()> {
+  if crate::cli::util::is_subject_to_app_translocation_on_macos() {
+    error_eprintln!("Unable to register Espanso to PATH, please move the Espanso.app bundle inside the /Applications directory to proceed.");
+    error_eprintln!(
+      "For more information, please see: https://github.com/federico-terzi/espanso/issues/844"
+    );
+    bail!("macOS activated app-translocation on Espanso");
+  }
+
   let target_link_dir = PathBuf::from("/usr/local/bin");
   let exec_path = std::env::current_exe()?;
 
-  if !target_link_dir.is_dir() {
-    return Err(PathError::UsrLocalBinDirDoesNotExist.into());
-  }
-
   let target_link_path = target_link_dir.join("espanso");
 
-  if let Err(error) = std::os::unix::fs::symlink(&exec_path, &target_link_path) {
-    match error.kind() {
-      ErrorKind::PermissionDenied => {
-        if prompt_when_necessary {
-          warn!("target link file can't be accessed with current permissions, requesting elevated ones through AppleScript.");
+  if !target_link_dir.is_dir() {
+    warn!("/usr/local/bin folder does not exist, attempting to create one...");
 
-          let params = format!(
-            r##"do shell script "mkdir -p /usr/local/bin && ln -sf '{}' '{}'" with administrator privileges"##,
-            exec_path.to_string_lossy(),
-            target_link_path.to_string_lossy(),
-          );
+    if let Err(error) = create_dir_all(&target_link_dir) {
+      match error.kind() {
+        ErrorKind::PermissionDenied if prompt_when_necessary => {
+          warn!("target link file can't be accessed with current permissions, requesting elevated ones through AppleScript.");
 
-          let mut child = std::process::Command::new("osascript")
-            .args(&["-e", &params])
-            .spawn()?;
+          create_dir_and_link_with_applescript(&exec_path, &target_link_path)
+            .context("unable to create link with AppleScript")?;
 
-          let result = child.wait()?;
-          if !result.success() {
-            return Err(PathError::ElevationRequestFailure.into());
-          }
-        } else {
+          return Ok(());
+        }
+        _other_error => {
           return Err(PathError::SymlinkError(error).into());
         }
       }
+    }
+  }
+
+  if let Err(error) = std::os::unix::fs::symlink(&exec_path, &target_link_path) {
+    match error.kind() {
+      ErrorKind::PermissionDenied if prompt_when_necessary => {
+        warn!("target link file can't be accessed with current permissions, requesting elevated ones through AppleScript.");
+
+        create_dir_and_link_with_applescript(&exec_path, &target_link_path)
+          .context("unable to create link with AppleScript")?;
+
+        return Ok(());
+      }
       _other_error => {
         return Err(PathError::SymlinkError(error).into());
       }
@@ -71,6 +81,26 @@ pub fn add_espanso_to_path(prompt_when_necessary: bool) -> Result<()> {
   Ok(())
 }
 
+fn create_dir_and_link_with_applescript(exec_path: &Path, target_link_path: &Path) -> Result<()> {
+  let params = format!(
+    r##"do shell script "mkdir -p /usr/local/bin && ln -sf '{}' '{}'" with administrator privileges"##,
+    exec_path.to_string_lossy(),
+    target_link_path.to_string_lossy(),
+  );
+
+  let mut child = std::process::Command::new("osascript")
+    .args(&["-e", &params])
+    .spawn()?;
+
+  let result = child.wait()?;
+
+  if !result.success() {
+    return Err(PathError::ElevationRequestFailure.into());
+  }
+
+  Ok(())
+}
+
 pub fn remove_espanso_from_path(prompt_when_necessary: bool) -> Result<()> {
   let target_link_path = PathBuf::from("/usr/local/bin/espanso");
 
@@ -112,9 +142,6 @@ pub fn remove_espanso_from_path(prompt_when_necessary: bool) -> Result<()> {
 
 #[derive(Error, Debug)]
 pub enum PathError {
-  #[error("/usr/local/bin directory doesn't exist")]
-  UsrLocalBinDirDoesNotExist,
-
   #[error("symlink error: `{0}`")]
   SymlinkError(std::io::Error),
 
diff --git a/snapcraft.yaml b/snapcraft.yaml
index 92a5f118..4141d80d 100644
--- a/snapcraft.yaml
+++ b/snapcraft.yaml
@@ -1,5 +1,5 @@
 name: espanso
-version: 2.0.4-alpha
+version: 2.0.5-alpha
 summary: A Cross-platform Text Expander written in Rust
 description: |
   espanso is a Cross-platform, Text Expander written in Rust.


Test patch: