macOS-only Tauri plugin using CGEventTap FFI to intercept keyboard events at hardware level, enabling override of system shortcuts.
Want to see it work immediately?
git clone https://github.com/yigitkonur/tauri-plugin-macos-input-monitor
cd tauri-plugin-macos-input-monitor/examples/vanilla
pnpm install
pnpm tauri devPress F5 - dictation blocked! β
(Grant Input Monitoring permission when prompted, then restart)
What you're seeing: The example app demonstrates overriding macOS F5 dictation shortcut - a perfect real-world use case. Normally, pressing F5 triggers the "Enable Dictation?" popup. With this plugin, F5 is intercepted at hardware level BEFORE macOS sees it, giving your app full control. The green indicator confirms Input Monitoring permission is granted, and the event log shows real-time F5 detection with timestamps.
| Mode | Status | Launch Method |
|---|---|---|
Dev (pnpm tauri dev) |
β Works perfectly | Automatic |
| Release (without Developer ID) | Direct binary only |
Dev mode always works. Release builds require special handling - see Launch Services Limitation below.
macOS system shortcuts (F5 dictation, F3 mission control, etc.) cannot be overridden by standard keyboard APIs. The popular tauri-plugin-global-shortcut uses RegisterEventHotKey, which operates at application level with low priority. System shortcuts always win.
Developers have no way to:
- Override F5 to prevent dictation popup
- Intercept F3 before mission control
- Use function keys for custom app actions
This plugin uses raw CGEventTap FFI with HeadInsertEventTap to intercept keyboard events at the hardware level, BEFORE macOS system handlers see them.
Keyboard Hardware
β
CGEventTap (HID, HeadInsert) β Plugin intercepts HERE
β
System Shortcuts (dictation, etc.) β Never receives event!
β
Application Shortcuts
β
Window receives event
Cargo.toml:
[dependencies]
tauri-plugin-macos-input-monitor = "0.1"src-tauri/src/lib.rs:
use tauri_plugin_macos_input_monitor::{MacOSInputMonitorExt, Modifiers};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri-plugin-macos-input-monitor::init())
.setup(|app| {
// Override F5 dictation shortcut
let hotkey = tauri_plugin_macos_input_monitor::Hotkey {
keycodes: vec![96, 176], // F5 in both keyboard modes
modifiers: Modifiers::empty(),
consume: true, // Block system from seeing it
event_name: "f5-pressed".to_string(),
};
let manager = app.macos_input_monitor().manager.lock().unwrap();
let id = manager.register(hotkey)?;
println!("F5 hotkey registered: {}", id.0);
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}- Build and run your app
- macOS will prompt for Input Monitoring permission
- Grant permission in: System Settings β Privacy & Security β Input Monitoring
- Restart the app after granting permission
Without this permission, CGEventTap creation fails silently.
Dev mode works perfectly (pnpm tauri dev) β
Release builds have a limitation when launched via open command β
The Issue:
macOS Launch Services requires Developer ID signing for apps using CGEventTap with Input Monitoring. Without it:
- β
Direct binary execution works:
/YourApp.app/Contents/MacOS/binary - β Launch Services fails:
open YourApp.app
Why: macOS enforces stricter security for Input Monitoring when apps are launched through Launch Services vs direct execution. Read more: macOS Privacy Permissions Guide
Solutions:
For Local Development/Testing:
# Option 1: Run binary directly
/Applications/YourApp.app/Contents/MacOS/your-binary
# Option 2: Create double-clickable launcher
echo '#!/bin/bash\n/Applications/YourApp.app/Contents/MacOS/your-binary' > Launch.command
chmod +x Launch.command
# Double-click Launch.command from Finder!For Production Distribution:
# Requires Apple Developer account ($99/year)
codesign --deep --force --sign "Developer ID Application: Your Name (TEAM_ID)" \
--options=runtime YourApp.app
# Then open works properlyNote: This is a macOS security feature, not a plugin bug. The CGEventTap implementation is production-ready and works flawlessly when launched correctly.
pub struct Hotkey {
/// Keycodes to match (support multiple for same key in different modes)
pub keycodes: Vec<i64>,
/// Required modifier keys
pub modifiers: Modifiers,
/// Block event from reaching system (true = override system shortcuts)
pub consume: bool,
/// Tauri event name to emit when triggered
pub event_name: String,
}pub struct Modifiers {
pub command: bool, // Cmd/β key
pub option: bool, // Option/Alt key
pub control: bool, // Control key
pub shift: bool, // Shift key
}Helpers:
Modifiers::empty() // No modifiers
Modifiers::command() // Cmd only// Register hotkey
let id = manager.register(hotkey)?;
// Unregister hotkey
manager.unregister(&id)?;
// Check if registered
let is_active = manager.is_registered(&id);use tauri_plugin_macos_input_monitor::get_function_key_codes;
// Get both keycodes for F5
let f5_codes = get_function_key_codes(5); // [96, 176]Why Rust wrappers fail:
// core-graphics crate wrapper
return None; // Translates to Rust Option::None, NOT C NULL!
// Result: Event still dispatched to system βWhy raw FFI works:
extern "C" fn callback(...) -> CGEventRef {
return std::ptr::null_mut(); // Actual C NULL pointer
}
// Result: Event consumed, system never sees it β
macOS has TWO modes for function keys (configured in System Settings):
Standard Function Keys Mode:
- F5 = keycode
96
Media Keys Mode (MacBook default):
- F5 = keycode
176(keyboard brightness down)
Solution: Register BOTH keycodes!
keycodes: vec![96, 176] // Works in both modes| Key | Standard Mode | Media Keys Mode | Media Function |
|---|---|---|---|
| F1 | 122 | 145 | Brightness Down |
| F2 | 120 | 144 | Brightness Up |
| F3 | 99 | 160 | Mission Control |
| F4 | 118 | 131 | Launchpad |
| F5 | 96 | 176 | KB Brightness Down |
| F6 | 97 | 177 | KB Brightness Up |
| F7 | 98 | 180 | Rewind |
| F8 | 100 | 179 | Play/Pause |
| F9 | 101 | 178 | Fast Forward |
| F10 | 109 | 173 | Mute |
| F11 | 103 | 174 | Volume Down |
| F12 | 111 | 175 | Volume Up |
macOS includes internal flags that should be ignored when matching hotkeys:
// Internal macOS flags (IGNORE these):
const SECONDARY_FN_FLAG: u64 = 0x800000; // Fn key indicator
const NON_COALESCED_FLAG: u64 = 0x100; // Internal event flag
const CAPS_LOCK_FLAG: u64 = 0x10000; // Caps Lock state
const NUM_PAD_FLAG: u64 = 0x200000; // Numeric keypad
// User-intentional modifiers (CHECK these):
const CMD_FLAG: u64 = 0x100000;
const OPT_FLAG: u64 = 0x80000;
const CTRL_FLAG: u64 = 0x40000;
const SHIFT_FLAG: u64 = 0x20000;The plugin automatically strips internal flags when matching, so you only specify intentional modifiers.
CGEventTapCreate(
CG_HID_EVENT_TAP, // Hardware/System-wide tap
CG_HEAD_INSERT_EVENT_TAP, // Highest priority placement
CG_EVENT_TAP_OPTION_DEFAULT, // Active filter (can modify)
event_mask, // KeyDown events
callback, // Our extern "C" callback
null // No user info
)Key points:
HIDlocation = hardware level (lowest in stack)HeadInsertEventTap= inserted at head of event queue (highest priority)Defaultoption = active filter (can return NULL to consume)
When consume: true:
// In callback
if hotkey_matches {
return std::ptr::null_mut(); // C NULL - event consumed!
}macOS receives NULL and stops event dispatch completely. System never sees the keypress!
let hotkey = Hotkey {
keycodes: vec![96, 176], // F5 both modes
modifiers: Modifiers::empty(),
consume: true, // Block dictation
event_name: "f5-pressed".to_string(),
};
manager.register(hotkey)?;
// Listen for events
app.listen("f5-pressed", |event| {
println!("F5 pressed! Dictation blocked.");
});let hotkey = Hotkey {
keycodes: vec![99, 160], // F3 both modes
modifiers: Modifiers::empty(),
consume: true,
event_name: "f3-pressed".to_string(),
};let hotkey = Hotkey {
keycodes: vec![96, 176],
modifiers: Modifiers::command(),
consume: false, // Don't block, just monitor
event_name: "cmd-f5-pressed".to_string(),
};Check Input Monitoring permission:
- System Settings β Privacy & Security β Input Monitoring
- Ensure your app is listed and enabled
- Restart the app after granting permission
Check console logs:
β
CGEventTap created successfully β Should see this
π― Hotkey matched! keycode: 176 β When pressing F5
π« Consuming event (returning NULL) β Event blocked
If you don't see "CGEventTap created", permission is missing.
Different Mac models use different keycodes! Use the discovery utility:
// TODO: Implement keycode discovery commandOr check console logs when pressing keys - keycode is logged for all events.
Verify consume: true:
consume: true, // Must be true to block systemCheck you're returning NULL:
- Plugin uses raw FFI that returns
std::ptr::null_mut() - This is THE critical difference from other solutions
| Feature | global-shortcut | macos-input-monitor |
|---|---|---|
| API Used | RegisterEventHotKey | CGEventTap FFI |
| Priority | Application level | Hardware level |
| Override System Shortcuts | β No | β Yes |
| Cross-platform | β Yes (Win/Mac/Linux) | β macOS only |
| Event Consumption | Limited | β Full (C NULL) |
| Use Case | App-level shortcuts | System override |
When to use global-shortcut:
- Cross-platform apps
- Regular app shortcuts (Cmd+S, Cmd+Q)
- Don't need to override system
When to use macos-input-monitor:
- Need to override macOS system shortcuts
- F5, F3, or other keys with system bindings
- macOS-specific apps only
This plugin is the result of extensive research and experimentation. Here's what we learned:
Attempt 1: Use tauri-plugin-global-shortcut
- β Can't override system shortcuts
- Uses
RegisterEventHotKey(app-level API)
Attempt 2: Use core-graphics Rust crate
- β Event still reached system
Option<CGEvent>return doesn't translate to C NULL
Attempt 3: Raw CGEventTap FFI β
- Returns actual
std::ptr::null_mut() - Direct C API calls
- This works!
The plugin is designed to be thread-safe:
unsafe impl Send for EventTap {}
unsafe impl Sync for EventTap {}Why this is safe:
- Actual
CFMachPortReflives in dedicated thread - State protected by
Arc<Mutex<>> - Never share raw pointers across threads
- AppHandle is Send + Sync
pub fn extract_user_modifiers(raw_flags: u64) -> u64 {
// Bitwise AND with user modifier mask
raw_flags & (CMD_FLAG | OPT_FLAG | CTRL_FLAG | SHIFT_FLAG)
}This strips SecondaryFn, NonCoalesced, CapsLock - internal macOS flags that appear in raw event data but aren't user-intentional modifiers.
Contributions welcome! This plugin solves a real problem for macOS Tauri developers.
Areas for improvement:
- Interactive keycode discovery tool
- TypeScript API bindings
- More comprehensive keycode database
- Swift Package integration
- Accessibility permission helpers
MIT OR Apache-2.0
Built by Yigit Konur based on:
- EventTapper Swift library architecture
- Official Tauri plugin development guide
- Extensive CGEventTap API experimentation
Special thanks to:
- Tauri team for the plugin system
- EventTapper authors for Swift reference implementation
- macOS CoreGraphics documentation
- Tauri Plugin Development Guide
- CGEventTap Apple Documentation
- EventTapper Swift Library
- BetterTouchTool - Uses same technique
