diff --git a/Cargo.lock b/Cargo.lock index 9f83030..1aa2556 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -371,7 +371,7 @@ dependencies = [ [[package]] name = "espanso" -version = "0.7.1" +version = "0.7.2" dependencies = [ "backtrace 0.3.37 (registry+https://github.com/rust-lang/crates.io-index)", "chrono 0.4.9 (registry+https://github.com/rust-lang/crates.io-index)", diff --git a/Cargo.toml b/Cargo.toml index 88e884c..c9d4457 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "espanso" -version = "0.7.1" +version = "0.7.2" authors = ["Federico Terzi "] license = "GPL-3.0" description = "Cross-platform Text Expander written in Rust" @@ -10,7 +10,7 @@ edition = "2018" build="build.rs" [modulo] -version = "0.1.0" +version = "0.1.1" [dependencies] widestring = "0.4.0" diff --git a/native/libmacbridge/AppDelegate.h b/native/libmacbridge/AppDelegate.h index f35f5d1..e37bb71 100644 --- a/native/libmacbridge/AppDelegate.h +++ b/native/libmacbridge/AppDelegate.h @@ -29,5 +29,7 @@ - (void)applicationDidFinishLaunching:(NSNotification *)aNotification; - (IBAction) statusIconClick: (id) sender; - (IBAction) contextMenuClick: (id) sender; +- (void) updateIcon: (char *)iconPath; +- (void) setIcon: (char *)iconPath; @end \ No newline at end of file diff --git a/native/libmacbridge/AppDelegate.mm b/native/libmacbridge/AppDelegate.mm index cd24e8b..5bfb256 100644 --- a/native/libmacbridge/AppDelegate.mm +++ b/native/libmacbridge/AppDelegate.mm @@ -26,15 +26,8 @@ // Setup status icon if (show_icon) { myStatusItem = [[[NSStatusBar systemStatusBar] statusItemWithLength:NSSquareStatusItemLength] retain]; - - NSString *nsIconPath = [NSString stringWithUTF8String:icon_path]; - NSImage *statusImage = [[NSImage alloc] initWithContentsOfFile:nsIconPath]; - [statusImage setTemplate:YES]; - - [myStatusItem.button setImage:statusImage]; - [myStatusItem setHighlightMode:YES]; - [myStatusItem.button setAction:@selector(statusIconClick:)]; - [myStatusItem.button setTarget:self]; + + [self setIcon: icon_path]; } // Setup key listener @@ -66,6 +59,29 @@ }]; } +- (void) updateIcon: (char *)iconPath { + if (show_icon) { + [myStatusItem release]; + + [self setIcon: iconPath]; + } +} + +- (void) setIcon: (char *)iconPath { + if (show_icon) { + myStatusItem = [[[NSStatusBar systemStatusBar] statusItemWithLength:NSSquareStatusItemLength] retain]; + + NSString *nsIconPath = [NSString stringWithUTF8String:iconPath]; + NSImage *statusImage = [[NSImage alloc] initWithContentsOfFile:nsIconPath]; + [statusImage setTemplate:YES]; + + [myStatusItem.button setImage:statusImage]; + [myStatusItem setHighlightMode:YES]; + [myStatusItem.button setAction:@selector(statusIconClick:)]; + [myStatusItem.button setTarget:self]; + } +} + - (IBAction) statusIconClick: (id) sender { icon_click_callback(context_instance); } diff --git a/native/libmacbridge/bridge.h b/native/libmacbridge/bridge.h index 169c3cf..a50212c 100644 --- a/native/libmacbridge/bridge.h +++ b/native/libmacbridge/bridge.h @@ -26,12 +26,13 @@ extern "C" { extern void * context_instance; extern char * icon_path; +extern char * disabled_icon_path; extern int32_t show_icon; /* * Initialize the AppDelegate and check for accessibility permissions */ -int32_t initialize(void * context, const char * icon_path, int32_t show_icon); +int32_t initialize(void * context, const char * icon_path, const char * disabled_icon_path, int32_t show_icon); /* * Start the event loop indefinitely. Blocking call. @@ -117,6 +118,11 @@ typedef void (*ContextMenuClickCallback)(void * self, int32_t id); extern ContextMenuClickCallback context_menu_click_callback; extern "C" void register_context_menu_click_callback(ContextMenuClickCallback callback); +/* + * Update the tray icon status + */ +extern "C" void update_tray_icon(int32_t enabled); + // SYSTEM /* diff --git a/native/libmacbridge/bridge.mm b/native/libmacbridge/bridge.mm index bafe1ae..27cd5b1 100644 --- a/native/libmacbridge/bridge.mm +++ b/native/libmacbridge/bridge.mm @@ -33,6 +33,7 @@ extern "C" { void * context_instance; char * icon_path; +char * disabled_icon_path; int32_t show_icon; AppDelegate * delegate_ptr; @@ -40,9 +41,10 @@ KeypressCallback keypress_callback; IconClickCallback icon_click_callback; ContextMenuClickCallback context_menu_click_callback; -int32_t initialize(void * context, const char * _icon_path, int32_t _show_icon) { +int32_t initialize(void * context, const char * _icon_path, const char * _disabled_icon_path, int32_t _show_icon) { context_instance = context; icon_path = strdup(_icon_path); + disabled_icon_path = strdup(_disabled_icon_path); show_icon = _show_icon; AppDelegate *delegate = [[AppDelegate alloc] init]; @@ -74,6 +76,18 @@ int32_t headless_eventloop() { return 0; } +void update_tray_icon(int32_t enabled) { + dispatch_async(dispatch_get_main_queue(), ^(void) { + NSApplication * application = [NSApplication sharedApplication]; + char * iconPath = icon_path; + if (!enabled) { + iconPath = disabled_icon_path; + } + + [[application delegate] updateIcon: iconPath]; + }); +} + void send_string(const char * string) { char * stringCopy = strdup(string); dispatch_async(dispatch_get_main_queue(), ^(void) { diff --git a/native/libwinbridge/bridge.cpp b/native/libwinbridge/bridge.cpp index 6db15c9..ede2528 100644 --- a/native/libwinbridge/bridge.cpp +++ b/native/libwinbridge/bridge.cpp @@ -663,6 +663,25 @@ void trigger_copy() { SendInput(vec.size(), vec.data(), sizeof(INPUT)); } +int32_t are_modifiers_pressed() { + short ctrl_pressed = GetAsyncKeyState(VK_CONTROL); + short enter_pressed = GetAsyncKeyState(VK_RETURN); + short alt_pressed = GetAsyncKeyState(VK_MENU); + short shift_pressed = GetAsyncKeyState(VK_SHIFT); + short meta_pressed = GetAsyncKeyState(VK_LWIN); + short rmeta_pressed = GetAsyncKeyState(VK_RWIN); + if (((ctrl_pressed & 0x8000) + + (enter_pressed & 0x8000) + + (alt_pressed & 0x8000) + + (shift_pressed & 0x8000) + + (meta_pressed & 0x8000) + + (rmeta_pressed & 0x8000)) != 0) { + return 1; + } + + return 0; +} + // SYSTEM @@ -808,7 +827,7 @@ int32_t set_clipboard(wchar_t *text) { } int32_t get_clipboard(wchar_t *buffer, int32_t size) { - int32_t result = 0; + int32_t result = 1; if (!OpenClipboard(NULL)) { return -1; } diff --git a/native/libwinbridge/bridge.h b/native/libwinbridge/bridge.h index 7db802a..ae57213 100644 --- a/native/libwinbridge/bridge.h +++ b/native/libwinbridge/bridge.h @@ -97,6 +97,11 @@ extern "C" void trigger_shift_paste(); */ extern "C" void trigger_copy(); +/* + * Check whether keyboard modifiers (CTRL, CMD, SHIFT, ecc) are pressed + */ +extern "C" int32_t are_modifiers_pressed(); + // Detect current application commands /* diff --git a/snapcraft.yaml b/snapcraft.yaml index 5af5017..43e79e5 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -1,5 +1,5 @@ name: espanso -version: 0.7.1 +version: 0.7.2 summary: A Cross-platform Text Expander written in Rust description: | espanso is a Cross-platform, Text Expander written in Rust. diff --git a/src/bridge/macos.rs b/src/bridge/macos.rs index c9f8c9f..01846d7 100644 --- a/src/bridge/macos.rs +++ b/src/bridge/macos.rs @@ -29,7 +29,12 @@ pub struct MacMenuItem { #[allow(improper_ctypes)] #[link(name = "macbridge", kind = "static")] extern "C" { - pub fn initialize(s: *const c_void, icon_path: *const c_char, show_icon: i32); + pub fn initialize( + s: *const c_void, + icon_path: *const c_char, + disabled_icon_path: *const c_char, + show_icon: i32, + ); pub fn eventloop(); pub fn headless_eventloop(); @@ -51,6 +56,7 @@ extern "C" { pub fn register_icon_click_callback(cb: extern "C" fn(_self: *mut c_void)); pub fn show_context_menu(items: *const MacMenuItem, count: i32) -> i32; pub fn register_context_menu_click_callback(cb: extern "C" fn(_self: *mut c_void, id: i32)); + pub fn update_tray_icon(enabled: i32); // Keyboard pub fn register_keypress_callback( diff --git a/src/bridge/windows.rs b/src/bridge/windows.rs index dd7ed32..554056f 100644 --- a/src/bridge/windows.rs +++ b/src/bridge/windows.rs @@ -69,6 +69,7 @@ extern "C" { pub fn trigger_paste(); pub fn trigger_shift_paste(); pub fn trigger_copy(); + pub fn are_modifiers_pressed() -> i32; // PROCESSES diff --git a/src/context/macos.rs b/src/context/macos.rs index a5c9b54..284e0c7 100644 --- a/src/context/macos.rs +++ b/src/context/macos.rs @@ -34,6 +34,7 @@ use std::sync::Arc; use std::{fs, thread}; const STATUS_ICON_BINARY: &[u8] = include_bytes!("../res/mac/icon.png"); +const DISABLED_STATUS_ICON_BINARY: &[u8] = include_bytes!("../res/mac/icondisabled.png"); pub struct MacContext { pub send_channel: Sender, @@ -72,6 +73,7 @@ impl MacContext { // Initialize the status icon path let espanso_dir = super::get_data_dir(); let status_icon_target = espanso_dir.join("icon.png"); + let disabled_status_icon_target = espanso_dir.join("icondisabled.png"); if status_icon_target.exists() { info!("Status icon already initialized, skipping."); @@ -84,6 +86,19 @@ impl MacContext { }); } + if disabled_status_icon_target.exists() { + info!("Status icon (disabled) already initialized, skipping."); + } else { + fs::write(&disabled_status_icon_target, DISABLED_STATUS_ICON_BINARY).unwrap_or_else( + |e| { + error!( + "Error copying the Status Icon (disabled) to the espanso data directory: {}", + e + ); + }, + ); + } + unsafe { let context_ptr = &*context as *const MacContext as *const c_void; @@ -93,9 +108,17 @@ impl MacContext { let status_icon_path = CString::new(status_icon_target.to_str().unwrap_or_default()).unwrap_or_default(); + let disabled_status_icon_path = + CString::new(disabled_status_icon_target.to_str().unwrap_or_default()) + .unwrap_or_default(); let show_icon = if config.show_icon { 1 } else { 0 }; - initialize(context_ptr, status_icon_path.as_ptr(), show_icon); + initialize( + context_ptr, + status_icon_path.as_ptr(), + disabled_status_icon_path.as_ptr(), + show_icon, + ); } context @@ -146,6 +169,12 @@ impl MacContext { } } +pub fn update_icon(enabled: bool) { + unsafe { + crate::bridge::macos::update_tray_icon(if enabled { 1 } else { 0 }); + } +} + impl super::Context for MacContext { fn eventloop(&self) { // Start the SecureInput watcher thread diff --git a/src/context/mod.rs b/src/context/mod.rs index ea656fe..0f95483 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -50,7 +50,7 @@ pub fn new( #[cfg(target_os = "macos")] pub fn update_icon(enabled: bool) { - // TODO: add update icon on macOS + macos::update_icon(enabled); } #[cfg(target_os = "macos")] diff --git a/src/engine.rs b/src/engine.rs index 3406181..7bd7ce9 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -498,9 +498,14 @@ impl< if config.secure_input_notification && config.show_notifications { self.ui_manager.notify_delay(&format!("{} has activated SecureInput. Espanso won't work until you disable it.", app_name), 5000); } + + crate::context::update_icon(false); } SystemEvent::SecureInputDisabled => { info!("SecureInput has been disabled."); + + let is_enabled = self.enabled.borrow(); + crate::context::update_icon(*is_enabled); } SystemEvent::NotifyRequest(message) => { let config = self.config_manager.default_config(); diff --git a/src/extension/form.rs b/src/extension/form.rs index c0a3361..00ac091 100644 --- a/src/extension/form.rs +++ b/src/extension/form.rs @@ -74,7 +74,7 @@ impl super::Extension for FormExtension { .manager .invoke(&["form", "-i", "-"], &serialized_config); - // On macOS, after the form closes we have to wait until the user releases the modifier keys + // On macOS and Windows, after the form closes we have to wait until the user releases the modifier keys on_form_close(); if let Some(output) = output { @@ -95,9 +95,17 @@ impl super::Extension for FormExtension { } } -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] fn on_form_close() { - // NOOP on Windows and Linux + // NOOP on Linux +} + +#[cfg(target_os = "windows")] +fn on_form_close() { + let released = crate::keyboard::windows::wait_for_modifiers_release(); + if !released { + warn!("Wait for modifiers release timed out! Please after closing the form, release your modifiers keys (CTRL, CMD, ALT, SHIFT)"); + } } #[cfg(target_os = "macos")] diff --git a/src/keyboard/mod.rs b/src/keyboard/mod.rs index dece480..7a5c2ca 100644 --- a/src/keyboard/mod.rs +++ b/src/keyboard/mod.rs @@ -21,7 +21,7 @@ use crate::config::Configs; use serde::{Deserialize, Serialize}; #[cfg(target_os = "windows")] -mod windows; +pub mod windows; #[cfg(target_os = "linux")] mod linux; diff --git a/src/keyboard/windows.rs b/src/keyboard/windows.rs index be2cbb2..507d4dd 100644 --- a/src/keyboard/windows.rs +++ b/src/keyboard/windows.rs @@ -78,3 +78,15 @@ impl super::KeyboardManager for WindowsKeyboardManager { } } } + +pub fn wait_for_modifiers_release() -> bool { + let start = std::time::SystemTime::now(); + while start.elapsed().unwrap_or_default().as_millis() < 3000 { + let pressed = unsafe { crate::bridge::windows::are_modifiers_pressed() }; + if pressed == 0 { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + false +} diff --git a/src/main.rs b/src/main.rs index 6158d38..294ad3c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -97,6 +97,13 @@ fn main() { .help("(Optional) Link to GitHub repository") .required(false) .default_value("hub"), + ) + .arg( + Arg::with_name("proxy") + .help("Use a proxy, should be used as --proxy=https://proxy:1234") + .required(false) + .long("proxy") + .takes_value(true), ); let uninstall_subcommand = SubCommand::with_name("uninstall") @@ -1096,6 +1103,14 @@ fn install_main(_config_set: ConfigSet, matches: &ArgMatches) { repository = repository.trim_end_matches(".git") } + let proxy = match matches.value_of("proxy") { + Some(proxy) => { + println!("Using proxy: {}", proxy); + Some(proxy.to_string()) + } + None => None, + }; + let package_resolver = Box::new(ZipPackageResolver::new()); let allow_external: bool = if matches.is_present("external") { @@ -1131,7 +1146,7 @@ fn install_main(_config_set: ConfigSet, matches: &ArgMatches) { println!("Using cached package index, run 'espanso package refresh' to update it.") } - package_manager.install_package(package_name, allow_external) + package_manager.install_package(package_name, allow_external, proxy) } else { // Make sure the repo is a valid github url lazy_static! { @@ -1147,7 +1162,7 @@ fn install_main(_config_set: ConfigSet, matches: &ArgMatches) { if !allow_external { Ok(InstallResult::BlockedExternalPackage(repository.to_owned())) } else { - package_manager.install_package_from_repo(package_name, repository) + package_manager.install_package_from_repo(package_name, repository, proxy) } }; diff --git a/src/package/default.rs b/src/package/default.rs index e2cc3c9..69182e3 100644 --- a/src/package/default.rs +++ b/src/package/default.rs @@ -264,12 +264,13 @@ impl super::PackageManager for DefaultPackageManager { &self, name: &str, allow_external: bool, + proxy: Option, ) -> Result> { let package = self.get_package(name); match package { Some(package) => { if package.is_core || allow_external { - self.install_package_from_repo(name, &package.repo) + self.install_package_from_repo(name, &package.repo, proxy) } else { Ok(BlockedExternalPackage(package.original_repo)) } @@ -282,6 +283,7 @@ impl super::PackageManager for DefaultPackageManager { &self, name: &str, repo_url: &str, + proxy: Option, ) -> Result> { // Check if package is already installed let packages = self.list_local_packages_names(); @@ -294,7 +296,7 @@ impl super::PackageManager for DefaultPackageManager { .package_resolver .as_ref() .unwrap() - .clone_repo_to_temp(repo_url)?; + .clone_repo_to_temp(repo_url, proxy)?; let temp_package_dir = temp_dir.path().join(name); if !temp_package_dir.exists() { @@ -532,7 +534,7 @@ mod tests { assert_eq!( temp.package_manager - .install_package("doesnotexist", false) + .install_package("doesnotexist", false, None) .unwrap(), NotFoundInIndex ); @@ -548,7 +550,7 @@ mod tests { assert_eq!( temp.package_manager - .install_package("italian-accents", false) + .install_package("italian-accents", false, None) .unwrap(), AlreadyInstalled ); @@ -563,7 +565,7 @@ mod tests { assert_eq!( temp.package_manager - .install_package("dummy-package", false) + .install_package("dummy-package", false, None) .unwrap(), Installed ); @@ -589,7 +591,7 @@ mod tests { assert_eq!( temp.package_manager - .install_package("not-existing", false) + .install_package("not-existing", false, None) .unwrap(), NotFoundInRepo ); @@ -604,7 +606,7 @@ mod tests { assert_eq!( temp.package_manager - .install_package("dummy-package2", false) + .install_package("dummy-package2", false, None) .unwrap(), MissingPackageVersion ); @@ -619,7 +621,7 @@ mod tests { assert_eq!( temp.package_manager - .install_package("dummy-package3", false) + .install_package("dummy-package3", false, None) .unwrap(), UnableToParsePackageInfo ); @@ -634,7 +636,7 @@ mod tests { assert_eq!( temp.package_manager - .install_package("dummy-package4", false) + .install_package("dummy-package4", false, None) .unwrap(), UnableToParsePackageInfo ); @@ -649,7 +651,7 @@ mod tests { assert_eq!( temp.package_manager - .install_package("dummy-package", false) + .install_package("dummy-package", false, None) .unwrap(), Installed ); diff --git a/src/package/git.rs b/src/package/git.rs deleted file mode 100644 index f8d132b..0000000 --- a/src/package/git.rs +++ /dev/null @@ -1,33 +0,0 @@ -use tempfile::TempDir; -use std::error::Error; -use git2::Repository; -use super::PackageResolver; - -pub struct GitPackageResolver; - -impl GitPackageResolver { - pub fn new() -> GitPackageResolver { - return GitPackageResolver{}; - } -} - -impl super::PackageResolver for GitPackageResolver { - fn clone_repo_to_temp(&self, repo_url: &str) -> Result> { - let temp_dir = TempDir::new()?; - let _repo = Repository::clone(repo_url, temp_dir.path())?; - Ok(temp_dir) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::{TempDir, NamedTempFile}; - - #[test] - fn test_clone_temp_repository() { - let resolver = GitPackageResolver::new(); - let cloned_dir = resolver.clone_repo_to_temp("https://github.com/federico-terzi/espanso-hub-core").unwrap(); - assert!(cloned_dir.path().join("LICENSE").exists()); - } -} \ No newline at end of file diff --git a/src/package/mod.rs b/src/package/mod.rs index 1d99210..ea6d23d 100644 --- a/src/package/mod.rs +++ b/src/package/mod.rs @@ -34,11 +34,13 @@ pub trait PackageManager { &self, name: &str, allow_external: bool, + proxy: Option, ) -> Result>; fn install_package_from_repo( &self, name: &str, repo_url: &str, + proxy: Option, ) -> Result>; fn remove_package(&self, name: &str) -> Result>; @@ -47,7 +49,11 @@ pub trait PackageManager { } pub trait PackageResolver { - fn clone_repo_to_temp(&self, repo_url: &str) -> Result>; + fn clone_repo_to_temp( + &self, + repo_url: &str, + proxy: Option, + ) -> Result>; } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/src/package/zip.rs b/src/package/zip.rs index 52fcf9d..4912e5f 100644 --- a/src/package/zip.rs +++ b/src/package/zip.rs @@ -13,13 +13,26 @@ impl ZipPackageResolver { } impl super::PackageResolver for ZipPackageResolver { - fn clone_repo_to_temp(&self, repo_url: &str) -> Result> { + fn clone_repo_to_temp( + &self, + repo_url: &str, + proxy: Option, + ) -> Result> { let temp_dir = TempDir::new()?; let zip_url = repo_url.to_owned() + "/archive/master.zip"; + let mut client = reqwest::Client::builder(); + + if let Some(proxy) = proxy { + let proxy = reqwest::Proxy::https(&proxy).expect("unable to setup https proxy"); + client = client.proxy(proxy); + }; + + let client = client.build().expect("unable to create http client"); + // Download the archive from GitHub - let mut response = reqwest::get(&zip_url)?; + let mut response = client.get(&zip_url).send()?; // Extract zip file let mut buffer = Vec::new(); @@ -90,7 +103,7 @@ mod tests { fn test_clone_temp_repository() { let resolver = ZipPackageResolver::new(); let cloned_dir = resolver - .clone_repo_to_temp("https://github.com/federico-terzi/espanso-hub-core") + .clone_repo_to_temp("https://github.com/federico-terzi/espanso-hub-core", None) .unwrap(); assert!(cloned_dir.path().join("LICENSE").exists()); } diff --git a/src/res/mac/AppIcon.icns b/src/res/mac/AppIcon.icns new file mode 100644 index 0000000..d3f07c3 Binary files /dev/null and b/src/res/mac/AppIcon.icns differ diff --git a/src/res/mac/icondisabled.png b/src/res/mac/icondisabled.png new file mode 100644 index 0000000..a289156 Binary files /dev/null and b/src/res/mac/icondisabled.png differ diff --git a/src/res/mac/modulo.plist b/src/res/mac/modulo.plist new file mode 100644 index 0000000..9883bd4 --- /dev/null +++ b/src/res/mac/modulo.plist @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + {{{modulo_path}}} + CFBundleIconFile + AppIcon + CFBundleIconName + AppIcon> + CFBundleIdentifier + com.federicoterzi.modulo + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Modulo + CFBundlePackageType + APPL + CFBundleSignature + ???? + CFBundleVersion + 1.0 + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/src/ui/modulo/mac.rs b/src/ui/modulo/mac.rs new file mode 100644 index 0000000..1b55b2e --- /dev/null +++ b/src/ui/modulo/mac.rs @@ -0,0 +1,64 @@ +use log::info; +use std::os::unix::fs::symlink; +use std::path::PathBuf; + +const MODULO_APP_BUNDLE_NAME: &str = "Modulo.app"; +const MODULO_APP_BUNDLE_PLIST_CONTENT: &'static str = include_str!("../../res/mac/modulo.plist"); +const MODULO_APP_BUNDLE_ICON: &[u8] = include_bytes!("../../res/mac/AppIcon.icns"); + +pub fn generate_modulo_app_bundle(modulo_path: &str) -> Result { + let modulo_pathbuf = PathBuf::from(modulo_path); + let modulo_path: String = if !modulo_pathbuf.exists() { + // If modulo was taken from the PATH, we need to calculate the absolute path + // To do so, we use the `which` command + let output = std::process::Command::new("which") + .arg("modulo") + .output() + .expect("unable to call 'which' command to determine modulo's full path"); + let path = String::from_utf8_lossy(output.stdout.as_slice()); + let path = path.trim(); + + info!("Detected modulo's full path: {:?}", &path); + path.to_string() + } else { + modulo_path.to_owned() + }; + + let data_dir = crate::context::get_data_dir(); + + let modulo_app_dir = data_dir.join(MODULO_APP_BUNDLE_NAME); + + // Remove previous bundle if present + if modulo_app_dir.exists() { + std::fs::remove_dir_all(&modulo_app_dir)?; + } + + // Recreate the App bundle stub + std::fs::create_dir(&modulo_app_dir)?; + + let contents_dir = modulo_app_dir.join("Contents"); + std::fs::create_dir(&contents_dir)?; + + let macos_dir = contents_dir.join("MacOS"); + std::fs::create_dir(&macos_dir)?; + + let resources_dir = contents_dir.join("Resources"); + std::fs::create_dir(&resources_dir)?; + + // Generate the Plist file + let plist_content = MODULO_APP_BUNDLE_PLIST_CONTENT.replace("{{{modulo_path}}}", &modulo_path); + let plist_file = contents_dir.join("Info.plist"); + std::fs::write(plist_file, plist_content)?; + + // Copy the icon file + let icon_file = resources_dir.join("AppIcon.icns"); + std::fs::write(icon_file, MODULO_APP_BUNDLE_ICON)?; + + // Generate the symbolic link to the modulo binary + let target_link = macos_dir.join("modulo"); + symlink(modulo_path, &target_link)?; + + info!("Created Modulo APP stub at: {:?}", &target_link); + + Ok(target_link) +} diff --git a/src/ui/modulo/mod.rs b/src/ui/modulo/mod.rs index 1a19d2d..445d5a5 100644 --- a/src/ui/modulo/mod.rs +++ b/src/ui/modulo/mod.rs @@ -3,6 +3,9 @@ use log::{error, info}; use std::io::{Error, Write}; use std::process::{Child, Command, Output}; +#[cfg(target_os = "macos")] +mod mac; + pub struct ModuloManager { modulo_path: Option, } @@ -41,8 +44,23 @@ impl ModuloManager { } } - if let Some(ref modulo_path) = modulo_path { - info!("Using modulo at {:?}", modulo_path); + if let Some(ref path) = modulo_path { + info!("Using modulo at {:?}", path); + + // MacOS specific remark + // In order to give modulo the focus when spawning a form, modulo has to be + // wrapped inside an application bundle. Therefore, we generate a bundle + // at startup. + // See issue: https://github.com/federico-terzi/espanso/issues/430 + #[cfg(target_os = "macos")] + { + modulo_path = Some( + mac::generate_modulo_app_bundle(path) + .expect("unable to generate modulo app stub") + .to_string_lossy() + .to_string(), + ); + } } Self { modulo_path }