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
16 changes: 14 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ The UI morphs between two states: a compact spotlight-style input bar → an exp
- **`lib.rs`** — app setup: converts window to NSPanel (fullscreen overlay), registers tray, spawns hotkey listener, intercepts close events (hides instead of quits)
- **`commands.rs`** — `ask_ollama` Tauri command: streams newline-delimited JSON from Ollama, sends chunks via Tauri Channel
- **`screenshot.rs`** — `capture_full_screen_command` Tauri command: uses CoreGraphics FFI (`CGWindowListCreateImage`) to capture all displays excluding Thuki's own windows, writes a JPEG to a temp dir, and returns the path
- **`activator.rs`** — Core Graphics event tap watching for double-tap Control key (400ms window, 600ms cooldown); prompts for Accessibility permission, retries up to 6×
- **`activator.rs`** — Core Graphics event tap watching for double-tap Control key (400ms window, 600ms cooldown). The tap MUST use `CGEventTapLocation::HID` and `CGEventTapOptions::Default` — see the critical constraint note in "Key Design Constraints" below.

### Sandbox (`sandbox/`)

Expand Down Expand Up @@ -110,4 +110,16 @@ Never commit files generated by superpowers skills (design specs, implementation

- **macOS only** — uses NSPanel, Core Graphics event taps, macOS Control key
- **Privacy-first** — Ollama runs locally; Docker sandbox drops all capabilities and isolates network
- **Accessibility permission required** — hotkey listener uses a CGEventTap at session level
- **Three permissions required** — Accessibility (CGEventTap creation), Input Monitoring (cross-app key delivery), Screen Recording (/screen command)

### CGEventTap configuration — DO NOT CHANGE these two settings

The hotkey listener in `activator.rs` requires **both** of the following settings to work correctly across all apps. Either one alone is insufficient; changing either one will silently break cross-app hotkey detection.

**`CGEventTapLocation::HID`** — must be HID level, never `Session` or `AnnotatedSession`.

Session-level taps (`kCGSessionEventTap`) sit above the window server routing layer. Since macOS 15 Sequoia, macOS applies focus-based filtering at that layer: a Session-level tap only receives events while the tap's own process (or its launch-parent terminal) has focus. Switching to any other app silently stops all event delivery. HID-level taps receive events before they reach the window server, bypassing this filtering entirely. This is what Karabiner-Elements, BetterTouchTool, and every other reliable system-wide key interceptor uses.

**`CGEventTapOptions::Default`** — must be the default (active) tap, never `ListenOnly`.

`ListenOnly` taps are disabled by macOS secure input mode. Secure input activates whenever a password field is focused, when iTerm's "Secure Keyboard Entry" is enabled, or when certain other security contexts are active. When the tap is disabled, macOS sends `TapDisabledByUserInput` and stops delivering events. Active (`Default`) taps are not subject to this restriction. We still return `CallbackResult::Keep` in the callback so no events are blocked or modified — the tap is passive in practice even though it is registered as active.
132 changes: 113 additions & 19 deletions src-tauri/src/activator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,34 +173,86 @@ impl OverlayActivator {
}
}

/// Persistence layer that maintains the event loop through permission cycles.
/// Reason the event tap run loop exited.
enum TapExitReason {
/// Activator was intentionally stopped via [`OverlayActivator`]. Do not retry.
Deactivated,
/// CGEventTap::new failed (Accessibility permission not yet granted). Retry
/// after waiting for the user to grant permission.
CreationFailed,
/// The tap was created and the run loop ran, but macOS disabled the tap
/// (timeout or user-input disable) or the run loop exited for an unexpected
/// reason. Retry immediately — no permission change is needed.
TapDied,
}

/// Persistence layer that maintains the event loop through permission and
/// tap-death cycles.
///
/// Two distinct failure modes are handled separately:
/// - **Permission failure** (`CreationFailed`): tap could not be installed at
/// all. Waits [`PERMISSION_POLL_INTERVAL`] between attempts, up to
/// [`MAX_PERMISSION_ATTEMPTS`] total.
/// - **Tap death** (`TapDied`): tap was running but macOS disabled it (e.g.
/// `TapDisabledByTimeout`). Retries immediately with no attempt limit so the
/// listener recovers as fast as possible.
#[cfg_attr(coverage_nightly, coverage(off))]
fn run_loop_with_retry<F>(is_active: Arc<AtomicBool>, on_activation: Arc<F>)
where
F: Fn() + Send + Sync + 'static,
{
for attempt in 1..=MAX_PERMISSION_ATTEMPTS {
if attempt > 1 {
std::thread::sleep(PERMISSION_POLL_INTERVAL);
if !request_authorization(false) {
continue;
}
let mut permission_failures: u32 = 0;

loop {
if !is_active.load(Ordering::SeqCst) {
return;
}

if try_initialize_tap(&is_active, &on_activation) {
return; // Successfully established and running
match try_initialize_tap(&is_active, &on_activation) {
TapExitReason::Deactivated => return,

TapExitReason::TapDied => {
// Tap was running then killed by macOS. Reinstall immediately.
eprintln!("thuki: [activator] tap died — reinstalling");
permission_failures = 0;
}

TapExitReason::CreationFailed => {
permission_failures += 1;
if permission_failures >= MAX_PERMISSION_ATTEMPTS {
eprintln!(
"thuki: [error] activation listener failed after \
maximum retries; check system permissions."
);
return;
}
eprintln!(
"thuki: [activator] tap creation failed \
(attempt {permission_failures}/{MAX_PERMISSION_ATTEMPTS}); \
retrying in {}s",
PERMISSION_POLL_INTERVAL.as_secs()
);
std::thread::sleep(PERMISSION_POLL_INTERVAL);
}
}
}

eprintln!("thuki: [error] activation listener failed after maximum retries. check system permissions.");
}

/// Core initialization of the Mach event tap.
///
/// Returns the reason the run loop exited so the caller can decide whether
/// to retry.
#[cfg_attr(coverage_nightly, coverage(off))]
fn try_initialize_tap<F>(is_active: &Arc<AtomicBool>, on_activation: &Arc<F>) -> bool
fn try_initialize_tap<F>(is_active: &Arc<AtomicBool>, on_activation: &Arc<F>) -> TapExitReason
where
F: Fn() + Send + Sync + 'static,
{
let im_granted = crate::permissions::is_input_monitoring_granted();
eprintln!(
"thuki: [activator] Input Monitoring permission: granted={im_granted} \
(cross-app hotkey requires this)"
);

let state = Arc::new(Mutex::new(ActivationState {
last_trigger: None,
is_pressed: false,
Expand All @@ -211,14 +263,44 @@ where
let cb_on_activation = on_activation.clone();
let cb_state = state.clone();

// Create the event tap at the Session level. This requires Accessibility
// permission but does not require root/superuser privileges unlike HID level.
// Create the event tap at HID level — the lowest level before events reach
// any application. This is what Karabiner-Elements, BetterTouchTool, and
// every other reliable system-wide key interceptor uses.
//
// Session-level taps (kCGSessionEventTap) sit above the window server
// routing layer and are subject to focus-based filtering introduced in
// macOS 15 Sequoia: they silently receive zero events from other apps.
// HID-level taps bypass this entirely and require only Accessibility
// permission, which Thuki already holds.
let tap_result = CGEventTap::new(
CGEventTapLocation::Session,
CGEventTapLocation::HID,
CGEventTapPlacement::HeadInsertEventTap,
CGEventTapOptions::ListenOnly,
// Use Default (active) tap, not ListenOnly. Active taps at HID level
// are not disabled by secure input mode (iTerm Secure Keyboard Entry,
// password fields, etc.). We still return CallbackResult::Keep so no
// events are blocked or modified. Requires Accessibility permission,
// which Thuki already holds.
CGEventTapOptions::Default,
// Only register for FlagsChanged. TapDisabledByTimeout and
// TapDisabledByUserInput have sentinel values (0xFFFFFFFE/0xFFFFFFFF)
// that overflow the bitmask and cannot be included here — macOS delivers
// them to the callback automatically without registration.
vec![CGEventType::FlagsChanged],
move |_proxy, _event_type, event: &CGEvent| -> CallbackResult {
move |_proxy, event_type, event: &CGEvent| -> CallbackResult {
// macOS auto-disables event taps whose callback is too slow.
// Stop the run loop so the outer retry loop reinstalls the tap.
if matches!(
event_type,
CGEventType::TapDisabledByTimeout | CGEventType::TapDisabledByUserInput
) {
eprintln!(
"thuki: [activator] event tap disabled by macOS \
({event_type:?}) — stopping run loop for reinstall"
);
CFRunLoop::get_current().stop();
return CallbackResult::Keep;
}

if !cb_active.load(Ordering::SeqCst) {
CFRunLoop::get_current().stop();
return CallbackResult::Keep;
Expand Down Expand Up @@ -246,6 +328,7 @@ where

match tap_result {
Ok(tap) => {
eprintln!("thuki: [activator] event tap created (HID level) — listening for double-tap Control");
unsafe {
let loop_source = tap
.mach_port()
Expand All @@ -258,9 +341,20 @@ where

CFRunLoop::run_current();
}
true
eprintln!("thuki: [activator] event tap run loop exited");
// If still supposed to be active the run loop exited unexpectedly.
if is_active.load(Ordering::SeqCst) {
TapExitReason::TapDied
} else {
TapExitReason::Deactivated
}
}
Err(()) => {
eprintln!(
"thuki: [activator] event tap creation FAILED; check Accessibility permission"
);
TapExitReason::CreationFailed
}
Err(()) => false,
}
}

Expand Down
31 changes: 25 additions & 6 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,8 @@ fn monitor_info_fallback() -> (f64, f64, f64, f64) {
/// back to global coordinates for `set_position`.
#[cfg(target_os = "macos")]
fn show_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationContext) {
if OVERLAY_INTENDED_VISIBLE.swap(true, Ordering::SeqCst) {
let already_visible = OVERLAY_INTENDED_VISIBLE.swap(true, Ordering::SeqCst);
if already_visible {
return;
}

Expand Down Expand Up @@ -285,7 +286,8 @@ fn show_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationCo
window_anchor,
);
}
Err(_) => {
Err(e) => {
eprintln!("thuki: [show_overlay] get_webview_panel FAILED: {e:?}");
// Reset the flag so future activation attempts are not permanently blocked.
OVERLAY_INTENDED_VISIBLE.store(false, Ordering::SeqCst);
}
Expand Down Expand Up @@ -400,14 +402,15 @@ fn notify_frontend_ready(app_handle: tauri::AppHandle, db: tauri::State<history:
// screen again on the next launch.
let ax = permissions::is_accessibility_granted();
let sr = permissions::is_screen_recording_granted();
let im = permissions::is_input_monitoring_granted();

if !ax || !sr {
if !ax || !sr || !im {
let _ = onboarding::set_stage(&conn, &onboarding::OnboardingStage::Permissions);
show_onboarding_window(&app_handle, onboarding::OnboardingStage::Permissions);
return;
}

// Both permissions granted. If not yet complete, show intro.
// All permissions granted. If not yet complete, show intro.
if !matches!(stage, onboarding::OnboardingStage::Complete) {
let _ = onboarding::set_stage(&conn, &onboarding::OnboardingStage::Intro);
show_onboarding_window(&app_handle, onboarding::OnboardingStage::Intro);
Expand Down Expand Up @@ -679,9 +682,19 @@ pub fn run() {
// simulating Cmd+C against Thuki's own WebView would produce
// a macOS alert sound.
let is_visible = OVERLAY_INTENDED_VISIBLE.load(Ordering::SeqCst);
let ctx = crate::context::capture_activation_context(is_visible);
let handle = app_handle.clone();
let _ = app_handle.run_on_main_thread(move || toggle_overlay(&handle, ctx));
let handle2 = app_handle.clone();
// Dispatch context capture to a dedicated thread so the event
// tap callback returns immediately. AX attribute lookups and
// clipboard simulation can block for seconds (macOS AX default
// timeout is ~6 s) when the focused app does not implement the
// accessibility protocol. Blocking the tap callback freezes the
// CFRunLoop and silently prevents all future key events from
// being delivered to the activator.
std::thread::spawn(move || {
let ctx = crate::context::capture_activation_context(is_visible);
let _ = handle.run_on_main_thread(move || toggle_overlay(&handle2, ctx));
});
});
app.manage(activator);
}
Expand Down Expand Up @@ -749,6 +762,12 @@ pub fn run() {
#[cfg(not(coverage))]
permissions::open_accessibility_settings,
#[cfg(not(coverage))]
permissions::check_input_monitoring_permission,
#[cfg(not(coverage))]
permissions::request_input_monitoring_access,
#[cfg(not(coverage))]
permissions::open_input_monitoring_settings,
#[cfg(not(coverage))]
permissions::check_screen_recording_permission,
#[cfg(not(coverage))]
permissions::open_screen_recording_settings,
Expand Down
Loading