diff --git a/Cargo.lock b/Cargo.lock index 716e52c..9572b17 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 b60f56d..91ed16a 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 157bdd5..c916dac 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 818578d..5723ccc 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 d7d6337..4f5f5c3 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::() { + 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 505c043..48314c1 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, diff --git a/espanso-engine/src/event/external.rs b/espanso-engine/src/event/external.rs new file mode 100644 index 0000000..91ae9d1 --- /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 . + */ + +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq)] +pub struct MatchExecRequestEvent { + pub trigger: Option, + pub args: HashMap, +} diff --git a/espanso-engine/src/event/mod.rs b/espanso-engine/src/event/mod.rs index 8769e0e..2042588 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 b0f85b8..4bb8efd 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 5b7e633..41438cf 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 7deec85..ff1bea2 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("

") { - html = html.trim_start_matches("

"); - } - if html.ends_with("

") { - html = html.trim_end_matches("

"); - } + 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("

").count(); + if paragraph_count <= 1 { + let mut new_html = html; + if new_html.starts_with("

") { + new_html = new_html.trim_start_matches("

"); + } + if new_html.ends_with("

") { + new_html = new_html.trim_end_matches("

"); + } + + 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("

single occurrence

"), + "single occurrence" + ); + assert_eq!( + remove_paragraph_tag_if_single_occurrence("

multi

occurrence

"), + "

multi

occurrence

" + ); + } +} 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 0000000..5b8a669 --- /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 . + */ + +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; +} + +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 9ba98b3..d42e5c5 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 0000000..e0e6aa9 --- /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 . + */ + +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 c227c0c..d8175e0 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 29421e2..75bf67d 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 1e295f9..f55e520 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 5a962b1..381dd88 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 { - 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 ebacf48..4445b31 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 86bc21d..53a995b 100644 --- a/espanso-modulo/build.rs +++ b/espanso-modulo/build.rs @@ -420,15 +420,26 @@ fn macos_link_search_path() -> Option { #[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 722cd6b..3481314 100644 --- a/espanso-modulo/src/sys/form/form.cpp +++ b/espanso-modulo/src/sys/form/form.cpp @@ -89,11 +89,16 @@ public: std::vector fields; std::unordered_map> 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 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 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 0e16d13..a06f796 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 a4b94cf..61b965d 100644 --- a/espanso-modulo/src/sys/wizard/wizard.cpp +++ b/espanso-modulo/src/sys/wizard/wizard.cpp @@ -115,6 +115,7 @@ protected: 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 913ec19..30500df 100644 --- a/espanso-modulo/src/sys/wizard/wizard.fbp +++ b/espanso-modulo/src/sys/wizard/wizard.fbp @@ -708,7 +708,7 @@ - -1 + 500 @@ -758,7 +758,7 @@ 0 0 wxID_ANY - Start + Quit 0 diff --git a/espanso-modulo/src/sys/wizard/wizard_gui.cpp b/espanso-modulo/src/sys/wizard/wizard_gui.cpp index bb53755..b5bbe8e 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 a31cae3..a80bffe 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 "] 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 0000000..1dcd462 --- /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 . + */ + +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 1b1c2fe..206ad73 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 aa77a19..4f40f1e 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 0000000..c2d859e --- /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 . + */ + +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 0000000..53614de --- /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 . + */ + +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, + match_store: Box, +) -> 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, + 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 0000000..781bc32 --- /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 . + */ + +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 2cdba94..f7a3768 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 f6914f5..5529c15 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 . */ -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 c80e418..6cf18fa 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 . */ +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 { - info_println!("espanso started correctly!"); } } - SERVICE_SUCCESS + 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)); + } + + 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 e029a74..335009a 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 0000000..0708358 --- /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 . + */ + +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, + pub sequencer: &'a Sequencer, +} + +impl<'a> IpcEventSource<'a> { + pub fn new(ipc_event_receiver: Receiver, 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 { + 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 f1d699b..c0706fc 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 3e4da03..ad9437d 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 { - 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 f68309a..07c653a 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, use_evdev_backend: bool, start_reason: Option, + ipc_event_receiver: Receiver, ) -> Result> { 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, + ¬ification_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 07f6177..1ae545e 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) -> Result<()> { +pub fn initialize_and_spawn( + runtime_dir: &Path, + exit_notify: Sender, + event_notify: Sender, +) -> 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) - 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) - Ok(()) } + +fn send_event( + event_notify: &Sender, + event: EventType, +) -> EventHandlerResponse { + 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 fe23b7d..6c2423f 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 { + let user_matches: Vec = 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 = 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 473b5f8..b2cf019 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 c08f139..9d18479 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 01ade17..c3bd78a 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 4d157d4..5e8d3b4 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, + pub args: HashMap, } pub fn create_daemon_ipc_server(runtime_dir: &Path) -> Result> { diff --git a/espanso/src/main.rs b/espanso/src/main.rs index d0897ed..58cf84f 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 = 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 58f020a..cd0f7c2 100644 --- a/espanso/src/path/macos.rs +++ b/espanso/src/path/macos.rs @@ -17,11 +17,11 @@ * along with espanso. If not, see . */ -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,38 +29,48 @@ 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 !target_link_dir.is_dir() { + warn!("/usr/local/bin folder does not exist, attempting to create one..."); + + 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."); + + 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()); + } + } + } + } + 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."); + ErrorKind::PermissionDenied if prompt_when_necessary => { + warn!("target link file can't be accessed with current permissions, requesting elevated ones through AppleScript."); - 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(), - ); + create_dir_and_link_with_applescript(&exec_path, &target_link_path) + .context("unable to create link with AppleScript")?; - let mut child = std::process::Command::new("osascript") - .args(&["-e", ¶ms]) - .spawn()?; - - let result = child.wait()?; - if !result.success() { - return Err(PathError::ElevationRequestFailure.into()); - } - } else { - return Err(PathError::SymlinkError(error).into()); - } + 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", ¶ms]) + .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 92a5f11..4141d80 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.