Repository: espanso/espanso (language: rust)

Issue:
Change menu bar logo when disabled (MacOS)
It can be useful to have a dedicated espanso logo in the menu bar whenever it is disabled. I would suggest a crossed 'e'.


Discussion / hints:
Hey @pedrorrivero,

Thank you for the feedback! This is indeed a good idea and I was going to add it myself in one of the next releases. So let's keep this issue open to stay updated

Cheers :)

Let me help you out, see if you like this logo:

![icon](https://user-images.githubusercontent.com/52980156/91480888-36fd4700-e869-11ea-9479-c4eda87f2900.png)

@pedrorrivero Thank you, that could be a good possibility. It might be a good idea to combine it with a lower opacity so that it's really clear that espanso is disabled. Not sure what's the best way to do that with the mac bar yet
The menu bar accepts an alpha channel so this should suffice (50% opacity):
<img width="88" alt="espanso-off-trans" src="https://user-images.githubusercontent.com/52980156/91502011-a8022600-e88c-11ea-899a-49af7f0cc23d.png">
I have also updated the original icon:
![espanso-off](https://user-images.githubusercontent.com/52980156/91502018-ac2e4380-e88c-11ea-8b74-6e30189290f8.png)

Thank you @pedrorrivero, I'll make some experiments with your icons and get back to you :)

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 9f830302..1aa25560 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 88e884cc..c9d4457b 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 <federicoterzi96@gmail.com>"]
 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 f35f5d1d..e37bb71b 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 cd24e8b4..5bfb2568 100644
--- a/native/libmacbridge/AppDelegate.mm
+++ b/native/libmacbridge/AppDelegate.mm
@@ -26,15 +26,8 @@ - (void)applicationDidFinishLaunching:(NSNotification *)aNotification
     // 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)applicationDidFinishLaunching:(NSNotification *)aNotification
     }];
 }
 
+- (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 169c3cf2..a50212c3 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 bafe1ae3..27cd5b1f 100644
--- a/native/libmacbridge/bridge.mm
+++ b/native/libmacbridge/bridge.mm
@@ -33,6 +33,7 @@
 
 void * context_instance;
 char * icon_path;
+char * disabled_icon_path;
 int32_t show_icon;
 AppDelegate * delegate_ptr;
 
@@ -40,9 +41,10 @@
 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 6db15c9d..ede25287 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 7db802a5..ae57213d 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 5af50174..43e79e5a 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 c9f8c9fa..01846d71 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 dd7ed324..554056f4 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 a5c9b54f..284e0c74 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<Event>,
@@ -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 ea656feb..0f954833 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 34061815..7bd7ce9e 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 c0a3361a..00ac0914 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 dece4803..7a5c2caf 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 be2cbb2a..507d4dd9 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 6158d389..294ad3c3 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 e2cc3c9a..69182e3f 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<String>,
     ) -> Result<InstallResult, Box<dyn Error>> {
         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<String>,
     ) -> Result<InstallResult, Box<dyn Error>> {
         // 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 f8d132ba..00000000
--- 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<TempDir, Box<dyn Error>> {
-        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 1d992105..ea6d23d4 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<String>,
     ) -> Result<InstallResult, Box<dyn Error>>;
     fn install_package_from_repo(
         &self,
         name: &str,
         repo_url: &str,
+        proxy: Option<String>,
     ) -> Result<InstallResult, Box<dyn Error>>;
 
     fn remove_package(&self, name: &str) -> Result<RemoveResult, Box<dyn Error>>;
@@ -47,7 +49,11 @@ pub trait PackageManager {
 }
 
 pub trait PackageResolver {
-    fn clone_repo_to_temp(&self, repo_url: &str) -> Result<TempDir, Box<dyn Error>>;
+    fn clone_repo_to_temp(
+        &self,
+        repo_url: &str,
+        proxy: Option<String>,
+    ) -> Result<TempDir, Box<dyn Error>>;
 }
 
 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
diff --git a/src/package/zip.rs b/src/package/zip.rs
index 52fcf9d4..4912e5f3 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<TempDir, Box<dyn Error>> {
+    fn clone_repo_to_temp(
+        &self,
+        repo_url: &str,
+        proxy: Option<String>,
+    ) -> Result<TempDir, Box<dyn Error>> {
         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 00000000..d3f07c39
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 00000000..a289156c
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 00000000..9883bd42
--- /dev/null
+++ b/src/res/mac/modulo.plist
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>CFBundleDevelopmentRegion</key>
+	<string>English</string>
+	<key>CFBundleExecutable</key>
+	<string>{{{modulo_path}}}</string>
+	<key>CFBundleIconFile</key>
+	<string>AppIcon</string>
+	<key>CFBundleIconName</key>
+	<string>AppIcon</string>>
+	<key>CFBundleIdentifier</key>
+	<string>com.federicoterzi.modulo</string>
+	<key>CFBundleInfoDictionaryVersion</key>
+	<string>6.0</string>
+	<key>CFBundleName</key>
+	<string>Modulo</string>
+	<key>CFBundlePackageType</key>
+	<string>APPL</string>
+	<key>CFBundleSignature</key>
+	<string>????</string>
+	<key>CFBundleVersion</key>
+	<string>1.0</string>
+	<key>NSMainNibFile</key>
+	<string>MainMenu</string>
+	<key>NSPrincipalClass</key>
+	<string>NSApplication</string>
+</dict>
+</plist>
diff --git a/src/ui/modulo/mac.rs b/src/ui/modulo/mac.rs
new file mode 100644
index 00000000..1b55b2eb
--- /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<PathBuf, std::io::Error> {
+    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 1a19d2d9..445d5a5f 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<String>,
 }
@@ -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 }


Test patch: