Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ x11rb = { version = "0.13", features = ["allow-unsafe-code"] }
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.5"
objc2-app-kit = { version = "0.2", features = ["NSWorkspace", "NSRunningApplication"] }
core-foundation = "0.9"

[profile.release]
opt-level = 3
Expand Down
106 changes: 105 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,21 @@ impl App {
let app_filter = AppFilter::new(self.config.exclusions.apps.clone());
let app_filter = Arc::new(Mutex::new(app_filter));

// On macOS, check for Accessibility permissions early and warn the user
#[cfg(target_os = "macos")]
{
if !macos_accessibility_check() {
log::error!(
"Accessibility permissions NOT granted! Keycen requires Accessibility access \
to intercept and simulate keystrokes. Please grant access in: \
System Settings > Privacy & Security > Accessibility. \
Add this application's binary to the list and restart."
);
} else {
log::info!("Accessibility permissions verified ✓");
}
}

// Determine input mode
let requested_mode = match self.config.general.mode.as_str() {
"grab" => InputMode::Grab,
Expand Down Expand Up @@ -145,7 +160,7 @@ impl App {
}
});

// Main thread: pump Windows messages + handle tray menu events
// Main thread: pump native messages + handle tray menu events
let mut current_enabled = enabled;
loop {
// Pump Windows messages so the tray context menu works
Expand All @@ -163,6 +178,17 @@ impl App {
}
}

// On macOS, pump the main run loop so that:
// 1. The system tray icon and context menu events are processed correctly
// 2. Any main-thread dispatch queues can execute (needed by some frameworks)
#[cfg(target_os = "macos")]
{
unsafe {
use core_foundation::runloop::{kCFRunLoopDefaultMode, CFRunLoopRunInMode};
CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.05, 0);
}
}

if let Ok(event) = MenuEvent::receiver().try_recv() {
if event.id == tray_menu.toggle_item.id() {
current_enabled = !current_enabled;
Expand Down Expand Up @@ -202,6 +228,8 @@ impl App {
}
}
// Small sleep to avoid busy-waiting
// On macOS, CFRunLoopRunInMode already provides the delay
#[cfg(not(target_os = "macos"))]
thread::sleep(Duration::from_millis(50));
}
}
Expand Down Expand Up @@ -244,3 +272,79 @@ fn config_watch_loop(
thread::sleep(Duration::from_secs(1));
}
}

/// Check if the process has Accessibility permissions on macOS.
/// This is required for both listening to and simulating keyboard events.
#[cfg(target_os = "macos")]
fn macos_accessibility_check() -> bool {
use std::ffi::c_void;

#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXIsProcessTrusted() -> bool;
}

// Also try to prompt the user with the system dialog
#[link(name = "CoreFoundation", kind = "framework")]
extern "C" {
fn CFStringCreateWithCString(
alloc: *const c_void,
c_str: *const u8,
encoding: u32,
) -> *const c_void;
fn CFDictionaryCreate(
allocator: *const c_void,
keys: *const *const c_void,
values: *const *const c_void,
num_values: i64,
key_callbacks: *const c_void,
value_callbacks: *const c_void,
) -> *const c_void;
fn CFRelease(cf: *const c_void);
static kCFTypeDictionaryKeyCallBacks: c_void;
static kCFTypeDictionaryValueCallBacks: c_void;
static kCFBooleanTrue: *const c_void;
}

#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXIsProcessTrustedWithOptions(options: *const c_void) -> bool;
}

unsafe {
// First do a simple check
let trusted = AXIsProcessTrusted();
if trusted {
return true;
}

// If not trusted, try prompting the system dialog
let key_cstr = b"AXTrustedCheckOptionPrompt\0";
let key = CFStringCreateWithCString(
std::ptr::null(),
key_cstr.as_ptr(),
0x08000100, // kCFStringEncodingUTF8
);
if !key.is_null() {
let keys = [key];
let values = [kCFBooleanTrue];
let options = CFDictionaryCreate(
std::ptr::null(),
keys.as_ptr(),
values.as_ptr(),
1,
&kCFTypeDictionaryKeyCallBacks as *const _,
&kCFTypeDictionaryValueCallBacks as *const _,
);
if !options.is_null() {
let result = AXIsProcessTrustedWithOptions(options);
CFRelease(options);
CFRelease(key);
return result;
}
CFRelease(key);
}

false
}
}
10 changes: 6 additions & 4 deletions src/buffer/classifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ pub enum KeyClass {
}

/// Classify a key event into a buffer action
pub fn classify_key(key: Key, name: Option<&str>, ctrl_held: bool) -> KeyClass {
// Check for paste operation (Ctrl+V)
if ctrl_held {
pub fn classify_key(key: Key, name: Option<&str>, ctrl_held: bool, meta_held: bool) -> KeyClass {
// Check for paste/undo/cut operations
// On macOS, Cmd (Meta) is used instead of Ctrl for these shortcuts
let shortcut_held = ctrl_held || meta_held;
if shortcut_held {
match key {
Key::KeyV => return KeyClass::Paste,
Key::KeyZ | Key::KeyX => return KeyClass::BufferReset,
_ => return KeyClass::Ignore, // Other ctrl combos don't produce text
_ => return KeyClass::Ignore, // Other shortcut combos don't produce text
}
}

Expand Down
12 changes: 11 additions & 1 deletion src/buffer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,16 @@ pub enum BufferAction {
pub struct WordBuffer {
buffer: String,
ctrl_held: bool,
/// On macOS, Cmd (Meta) is used instead of Ctrl for shortcuts like paste
meta_held: bool,
}

impl WordBuffer {
pub fn new() -> Self {
WordBuffer {
buffer: String::with_capacity(64),
ctrl_held: false,
meta_held: false,
}
}

Expand All @@ -39,10 +42,14 @@ impl WordBuffer {
self.ctrl_held = true;
return BufferAction::Ignored;
}
Key::MetaLeft | Key::MetaRight => {
self.meta_held = true;
return BufferAction::Ignored;
}
_ => {}
}

let class = classify_key(key, name, self.ctrl_held);
let class = classify_key(key, name, self.ctrl_held, self.meta_held);

match class {
KeyClass::WordChar(ch) => {
Expand Down Expand Up @@ -77,6 +84,9 @@ impl WordBuffer {
Key::ControlLeft | Key::ControlRight => {
self.ctrl_held = false;
}
Key::MetaLeft | Key::MetaRight => {
self.meta_held = false;
}
_ => {}
}
}
Expand Down
6 changes: 5 additions & 1 deletion src/correction/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ pub static IS_SIMULATING: AtomicBool = AtomicBool::new(false);
pub static CORRECTION_IN_PROGRESS: AtomicBool = AtomicBool::new(false);

/// Delay between simulated keystrokes (ms)
/// Keep this minimal to avoid interleaving with user typing
/// macOS requires longer delays for the OS to process simulated events.
/// The rdev docs recommend at least 20ms on macOS.
#[cfg(target_os = "macos")]
const SIMULATE_DELAY_MS: u64 = 20;
#[cfg(not(target_os = "macos"))]
const SIMULATE_DELAY_MS: u64 = 1;

/// Maximum word length we'll attempt to correct (safety limit)
Expand Down