From ead5da0c239bcddaf8de3d49d889a8e0bc47f4d0 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Tue, 7 Apr 2026 21:52:18 -0500 Subject: [PATCH 1/3] fix: restore cross-app hotkey via HID tap + active tap options The double-tap Control hotkey stopped working whenever focus left Thuki or its launch terminal. Two independent bugs combined to cause this. Bug 1: CGEventTapLocation::Session filtered by window server focus Since macOS 15 Sequoia, Session-level taps only receive events while the tap process (or its parent terminal) has focus. Switching to any other app silently stops all event delivery with no error. Fixed by using CGEventTapLocation::HID, which operates before the window server routing layer and receives all keypress events regardless of which app is active. Bug 2: CGEventTapOptions::ListenOnly disabled by secure input mode ListenOnly taps are disabled by macOS secure input mode, which activates when iTerm has "Secure Keyboard Entry" enabled or any password field is focused. macOS sends TapDisabledByUserInput and halts delivery. Fixed by using CGEventTapOptions::Default (active tap). We return CallbackResult::Keep so no events are blocked. Additional changes: - Add Input Monitoring (kTCCServiceListenEvent) as a required permission alongside Accessibility and Screen Recording. The IOHIDCheckAccess/IOHIDRequestAccess FFI and three new Tauri commands wire it into the existing permission infrastructure. - Add Input Monitoring as step 2 in OnboardingView (between Accessibility and Screen Recording, no restart required). - Improve tap retry logic: distinguish tap-death (reinstall immediately) from creation failure (wait + limited retries). - Handle TapDisabledByTimeout/TapDisabledByUserInput in callback by stopping the run loop to trigger an immediate reinstall. - Document the HID + Default tap requirement in CLAUDE.md so these settings are never accidentally reverted. Signed-off-by: Logan Nguyen Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Logan Nguyen --- CLAUDE.md | 16 +- src-tauri/src/activator.rs | 135 ++++++- src-tauri/src/lib.rs | 48 ++- src-tauri/src/permissions.rs | 106 +++++- src/__tests__/OnboardingView.test.tsx | 452 ++++++++++++++++++++---- src/view/onboarding/PermissionsStep.tsx | 187 ++++++++-- 6 files changed, 815 insertions(+), 129 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 04dee00..872f2d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/`) @@ -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. diff --git a/src-tauri/src/activator.rs b/src-tauri/src/activator.rs index 9b3cdb6..9773fc9 100644 --- a/src-tauri/src/activator.rs +++ b/src-tauri/src/activator.rs @@ -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(is_active: Arc, on_activation: Arc) 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(is_active: &Arc, on_activation: &Arc) -> bool +fn try_initialize_tap(is_active: &Arc, on_activation: &Arc) -> 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, @@ -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; @@ -234,10 +316,13 @@ where // Check specific bitmask for the Control key state let is_press = flags.contains(CGEventFlags::CGEventFlagControl); + eprintln!("thuki: [activator] Ctrl key event: is_press={is_press}"); let mut s = cb_state.lock().unwrap(); if evaluate_activation(&mut s, is_press) { + eprintln!("thuki: [activator] double-tap detected — invoking activation handler"); cb_on_activation(); + eprintln!("thuki: [activator] activation handler returned"); } CallbackResult::Keep @@ -246,6 +331,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() @@ -258,9 +344,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, } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 39e3324..3ee1424 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -209,7 +209,9 @@ 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); + eprintln!("thuki: [show_overlay] already_visible={already_visible}"); + if already_visible { return; } @@ -277,15 +279,19 @@ fn show_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationCo match app_handle.get_webview_panel("main") { Ok(panel) => { + eprintln!("thuki: [show_overlay] calling show_and_make_key"); panel.show_and_make_key(); + eprintln!("thuki: [show_overlay] emitting show event"); emit_overlay_visibility( app_handle, OVERLAY_VISIBILITY_SHOW, selected_text, window_anchor, ); + eprintln!("thuki: [show_overlay] done"); } - 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); } @@ -323,7 +329,9 @@ fn show_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationCo /// Uses an atomic flag as the single source of truth for intended visibility, /// which avoids race conditions with the native panel state during animations. fn toggle_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationContext) { - if OVERLAY_INTENDED_VISIBLE.load(Ordering::SeqCst) { + let vis = OVERLAY_INTENDED_VISIBLE.load(Ordering::SeqCst); + eprintln!("thuki: [toggle_overlay] called, OVERLAY_INTENDED_VISIBLE={vis}"); + if vis { request_overlay_hide(app_handle); } else { show_overlay(app_handle, ctx); @@ -365,6 +373,7 @@ fn set_window_frame(app_handle: tauri::AppHandle, x: f64, y: f64, width: f64, he /// completes its exit animation and hides the native window. #[tauri::command] fn notify_overlay_hidden() { + eprintln!("thuki: [notify_overlay_hidden] called"); OVERLAY_INTENDED_VISIBLE.store(false, Ordering::SeqCst); } @@ -400,14 +409,15 @@ fn notify_frontend_ready(app_handle: tauri::AppHandle, db: tauri::State bool { - !accessibility || !screen_recording +/// All three permissions must be granted for Thuki to function fully: +/// - Accessibility: required to create the CGEventTap +/// - Input Monitoring: required for the tap to receive events from other apps +/// - Screen Recording: required for the /screen command +/// +/// If any is missing the onboarding screen is shown instead of the normal overlay. +pub fn needs_onboarding( + accessibility: bool, + screen_recording: bool, + input_monitoring: bool, +) -> bool { + !accessibility || !screen_recording || !input_monitoring } // ─── macOS Permission Checks ───────────────────────────────────────────────── @@ -29,6 +36,25 @@ extern "C" { fn AXIsProcessTrusted() -> bool; } +/// IOKit constants for Input Monitoring permission checks. +#[cfg(target_os = "macos")] +const IOHID_REQUEST_TYPE_LISTEN_EVENT: u32 = 1; +#[cfg(target_os = "macos")] +const IOHID_ACCESS_TYPE_GRANTED: u32 = 1; + +#[cfg(target_os = "macos")] +#[link(name = "IOKit", kind = "framework")] +extern "C" { + /// Checks whether the process has Input Monitoring access. + /// Returns kIOHIDAccessTypeGranted (1) if granted, 0 if not determined, + /// 2 if denied. + fn IOHIDCheckAccess(request_type: u32) -> u32; + + /// Requests Input Monitoring access, showing the system permission dialog + /// on first call. Returns true if access was granted. + fn IOHIDRequestAccess(request_type: u32) -> bool; +} + /// Returns whether the process currently has Accessibility permission. #[cfg(target_os = "macos")] #[cfg_attr(coverage_nightly, coverage(off))] @@ -36,6 +62,21 @@ pub fn is_accessibility_granted() -> bool { unsafe { AXIsProcessTrusted() } } +/// Returns whether the process currently has Input Monitoring permission. +/// +/// Input Monitoring (`kTCCServiceListenEvent`) is required for a CGEventTap at +/// Session level to receive keyboard events from other applications. Without it, +/// the tap only sees events generated within the Thuki process itself, making +/// the double-tap hotkey invisible when the user's focus is elsewhere. +/// +/// Unlike Screen Recording, Input Monitoring does not require a process restart +/// after being granted; the CGEventTap immediately begins receiving cross-app events. +#[cfg(target_os = "macos")] +#[cfg_attr(coverage_nightly, coverage(off))] +pub fn is_input_monitoring_granted() -> bool { + unsafe { IOHIDCheckAccess(IOHID_REQUEST_TYPE_LISTEN_EVENT) == IOHID_ACCESS_TYPE_GRANTED } +} + /// Returns whether the process currently has Screen Recording permission. /// /// Uses `CGPreflightScreenCaptureAccess`, which only returns `true` after @@ -61,6 +102,44 @@ pub fn check_accessibility_permission() -> bool { is_accessibility_granted() } +/// Returns whether Input Monitoring permission has been granted. +#[tauri::command] +#[cfg(target_os = "macos")] +#[cfg_attr(coverage_nightly, coverage(off))] +pub fn check_input_monitoring_permission() -> bool { + is_input_monitoring_granted() +} + +/// Triggers the macOS Input Monitoring permission dialog. +/// +/// `IOHIDRequestAccess` registers the app in TCC and shows the system-level +/// "X would like to monitor input events" prompt. If the user previously denied +/// the permission, the dialog is skipped and the call returns false; the +/// onboarding UI then directs the user to System Settings directly. +#[tauri::command] +#[cfg(target_os = "macos")] +#[cfg_attr(coverage_nightly, coverage(off))] +pub fn request_input_monitoring_access() { + unsafe { + IOHIDRequestAccess(IOHID_REQUEST_TYPE_LISTEN_EVENT); + } +} + +/// Opens System Settings to the Input Monitoring privacy pane. +#[tauri::command] +#[cfg(target_os = "macos")] +#[cfg_attr(coverage_nightly, coverage(off))] +pub fn open_input_monitoring_settings() -> Result<(), String> { + std::process::Command::new("open") + .arg( + "x-apple.systempreferences:com.apple.preference.security\ + ?Privacy_ListenEvent", + ) + .spawn() + .map(|_| ()) + .map_err(|e| e.to_string()) +} + /// Opens System Settings to the Accessibility privacy pane so the user can /// enable the permission without encountering the native system popup. /// @@ -165,22 +244,27 @@ mod tests { use super::*; #[test] - fn needs_onboarding_false_when_both_granted() { - assert!(!needs_onboarding(true, true)); + fn needs_onboarding_false_when_all_granted() { + assert!(!needs_onboarding(true, true, true)); } #[test] fn needs_onboarding_true_when_accessibility_missing() { - assert!(needs_onboarding(false, true)); + assert!(needs_onboarding(false, true, true)); } #[test] fn needs_onboarding_true_when_screen_recording_missing() { - assert!(needs_onboarding(true, false)); + assert!(needs_onboarding(true, false, true)); + } + + #[test] + fn needs_onboarding_true_when_input_monitoring_missing() { + assert!(needs_onboarding(true, true, false)); } #[test] - fn needs_onboarding_true_when_both_missing() { - assert!(needs_onboarding(false, false)); + fn needs_onboarding_true_when_all_missing() { + assert!(needs_onboarding(false, false, false)); } } diff --git a/src/__tests__/OnboardingView.test.tsx b/src/__tests__/OnboardingView.test.tsx index 8baefbd..020344a 100644 --- a/src/__tests__/OnboardingView.test.tsx +++ b/src/__tests__/OnboardingView.test.tsx @@ -13,16 +13,31 @@ describe('OnboardingView', () => { vi.useRealTimers(); }); - function setupPermissions(accessibility: boolean, screenRecording = false) { + /** + * Set up invoke mock for the standard permission check commands. + * - accessibility: whether check_accessibility_permission returns true + * - inputMonitoring: whether check_input_monitoring_permission returns true + * - screenRecording: whether check_screen_recording_tcc_granted returns true + */ + function setupPermissions( + accessibility: boolean, + inputMonitoring = false, + screenRecording = false, + ) { invoke.mockImplementation(async (cmd: string) => { if (cmd === 'check_accessibility_permission') return accessibility; + if (cmd === 'check_input_monitoring_permission') return inputMonitoring; if (cmd === 'check_screen_recording_permission') return screenRecording; if (cmd === 'check_screen_recording_tcc_granted') return false; if (cmd === 'request_screen_recording_access') return; if (cmd === 'open_screen_recording_settings') return; + if (cmd === 'request_input_monitoring_access') return; + if (cmd === 'open_input_monitoring_settings') return; }); } + // ─── Basic render ────────────────────────────────────────────────────────── + it('shows step 1 as active when accessibility is not granted', async () => { setupPermissions(false); render(); @@ -42,20 +57,19 @@ describe('OnboardingView', () => { expect(screen.getByText("Let's get Thuki set up")).toBeInTheDocument(); }); - it('skips to step 2 when accessibility is already granted on mount', async () => { - setupPermissions(true); + it('shows all three steps regardless of current active step', async () => { + setupPermissions(false); render(); await act(async () => {}); - expect( - screen.queryByRole('button', { name: /grant accessibility/i }), - ).toBeNull(); - expect( - screen.getByRole('button', { name: /open screen recording settings/i }), - ).toBeInTheDocument(); + expect(screen.getByText('Accessibility')).toBeInTheDocument(); + expect(screen.getByText('Input Monitoring')).toBeInTheDocument(); + expect(screen.getByText('Screen Recording')).toBeInTheDocument(); }); - it('clicking grant accessibility invokes request command', async () => { + // ─── Step 1: Accessibility ───────────────────────────────────────────────── + + it('clicking grant accessibility invokes open settings command', async () => { setupPermissions(false); render(); await act(async () => {}); @@ -69,7 +83,7 @@ describe('OnboardingView', () => { expect(invoke).toHaveBeenCalledWith('open_accessibility_settings'); }); - it('shows spinner while polling after grant request', async () => { + it('shows spinner while polling after accessibility grant request', async () => { setupPermissions(false); render(); await act(async () => {}); @@ -80,7 +94,6 @@ describe('OnboardingView', () => { ); }); - // Button should be disabled/spinner state while checking const btn = screen.getByRole('button', { name: /checking|grant accessibility/i, }); @@ -91,7 +104,7 @@ describe('OnboardingView', () => { let accessibilityGranted = false; invoke.mockImplementation(async (cmd: string) => { if (cmd === 'check_accessibility_permission') return accessibilityGranted; - if (cmd === 'check_screen_recording_permission') return false; + if (cmd === 'check_input_monitoring_permission') return false; if (cmd === 'open_accessibility_settings') return; }); @@ -104,17 +117,15 @@ describe('OnboardingView', () => { ); }); - // First poll fires but permission still false await act(async () => { await vi.advanceTimersByTimeAsync(500); }); - // Still on step 1, open screen recording button not yet shown + // Still on step 1, input monitoring button not yet shown expect( - screen.queryByRole('button', { name: /open screen recording settings/i }), + screen.queryByRole('button', { name: /grant input monitoring/i }), ).toBeNull(); - // Now grant it and fire second poll accessibilityGranted = true; await act(async () => { await vi.advanceTimersByTimeAsync(500); @@ -122,39 +133,35 @@ describe('OnboardingView', () => { // Step 2 now active expect( - screen.getByRole('button', { name: /open screen recording settings/i }), + screen.getByRole('button', { name: /grant input monitoring/i }), ).toBeInTheDocument(); }); - it('advances to step 2 when polling detects accessibility granted', async () => { + it('advances to step 2 (input monitoring) when polling detects accessibility granted', async () => { let accessibilityGranted = false; invoke.mockImplementation(async (cmd: string) => { if (cmd === 'check_accessibility_permission') return accessibilityGranted; - if (cmd === 'check_screen_recording_permission') return false; + if (cmd === 'check_input_monitoring_permission') return false; if (cmd === 'open_accessibility_settings') return; }); render(); await act(async () => {}); - // Click grant await act(async () => { fireEvent.click( screen.getByRole('button', { name: /grant accessibility/i }), ); }); - // Grant becomes true before next poll accessibilityGranted = true; - // Advance one poll interval await act(async () => { await vi.advanceTimersByTimeAsync(500); }); - // Step 2 should now be active expect( - screen.getByRole('button', { name: /open screen recording settings/i }), + screen.getByRole('button', { name: /grant input monitoring/i }), ).toBeInTheDocument(); }); @@ -162,7 +169,7 @@ describe('OnboardingView', () => { let accessibilityGranted = false; invoke.mockImplementation(async (cmd: string) => { if (cmd === 'check_accessibility_permission') return accessibilityGranted; - if (cmd === 'check_screen_recording_permission') return false; + if (cmd === 'check_input_monitoring_permission') return false; if (cmd === 'open_accessibility_settings') return; }); @@ -183,8 +190,168 @@ describe('OnboardingView', () => { expect(screen.getByText('Granted')).toBeInTheDocument(); }); + // ─── Mount: skip completed steps ────────────────────────────────────────── + + it('skips to step 2 when accessibility is already granted on mount', async () => { + setupPermissions(true, false); + render(); + await act(async () => {}); + + expect( + screen.queryByRole('button', { name: /grant accessibility/i }), + ).toBeNull(); + expect( + screen.getByRole('button', { name: /grant input monitoring/i }), + ).toBeInTheDocument(); + }); + + it('skips to step 3 when accessibility and input monitoring are both granted on mount', async () => { + setupPermissions(true, true); + render(); + await act(async () => {}); + + expect( + screen.queryByRole('button', { name: /grant accessibility/i }), + ).toBeNull(); + expect( + screen.queryByRole('button', { name: /grant input monitoring/i }), + ).toBeNull(); + expect( + screen.getByRole('button', { name: /open screen recording settings/i }), + ).toBeInTheDocument(); + }); + + // ─── Step 2: Input Monitoring ────────────────────────────────────────────── + + it('clicking grant input monitoring invokes request and open-settings commands', async () => { + setupPermissions(true, false); + render(); + await act(async () => {}); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /grant input monitoring/i }), + ); + }); + + expect(invoke).toHaveBeenCalledWith('request_input_monitoring_access'); + expect(invoke).toHaveBeenCalledWith('open_input_monitoring_settings'); + }); + + it('shows spinner while polling after input monitoring grant request', async () => { + setupPermissions(true, false); + render(); + await act(async () => {}); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /grant input monitoring/i }), + ); + }); + + const btn = screen.getByRole('button', { + name: /checking|grant input monitoring/i, + }); + expect(btn).toBeDisabled(); + }); + + it('keeps polling when input monitoring not yet granted on first poll interval', async () => { + let imGranted = false; + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'check_accessibility_permission') return true; + if (cmd === 'check_input_monitoring_permission') return imGranted; + if (cmd === 'request_input_monitoring_access') return; + if (cmd === 'open_input_monitoring_settings') return; + }); + + render(); + await act(async () => {}); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /grant input monitoring/i }), + ); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + + // Still on step 2, screen recording button not yet shown + expect( + screen.queryByRole('button', { name: /open screen recording settings/i }), + ).toBeNull(); + + imGranted = true; + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + + expect( + screen.getByRole('button', { name: /open screen recording settings/i }), + ).toBeInTheDocument(); + }); + + it('advances to step 3 when polling detects input monitoring granted', async () => { + let imGranted = false; + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'check_accessibility_permission') return true; + if (cmd === 'check_input_monitoring_permission') return imGranted; + if (cmd === 'request_input_monitoring_access') return; + if (cmd === 'open_input_monitoring_settings') return; + }); + + render(); + await act(async () => {}); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /grant input monitoring/i }), + ); + }); + + imGranted = true; + + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + + expect( + screen.getByRole('button', { name: /open screen recording settings/i }), + ).toBeInTheDocument(); + }); + + it('step 2 shows granted badge after input monitoring is detected', async () => { + let imGranted = false; + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'check_accessibility_permission') return true; + if (cmd === 'check_input_monitoring_permission') return imGranted; + if (cmd === 'request_input_monitoring_access') return; + if (cmd === 'open_input_monitoring_settings') return; + }); + + render(); + await act(async () => {}); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /grant input monitoring/i }), + ); + }); + + imGranted = true; + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + + const badges = screen.getAllByText('Granted'); + expect(badges.length).toBeGreaterThanOrEqual(2); + }); + + // ─── Step 3: Screen Recording ────────────────────────────────────────────── + it('clicking open screen recording settings registers app and opens settings', async () => { - setupPermissions(true); + setupPermissions(true, true); render(); await act(async () => {}); @@ -194,13 +361,12 @@ describe('OnboardingView', () => { ); }); - // Registers Thuki in TCC (so it appears in the list) then opens Settings expect(invoke).toHaveBeenCalledWith('request_screen_recording_access'); expect(invoke).toHaveBeenCalledWith('open_screen_recording_settings'); }); it('shows spinner while polling after opening screen recording settings', async () => { - setupPermissions(true); + setupPermissions(true, true); render(); await act(async () => {}); @@ -210,7 +376,6 @@ describe('OnboardingView', () => { ); }); - // Button should be disabled/spinner state while polling for tcc grant const btn = screen.getByRole('button', { name: /checking|open screen recording settings/i, }); @@ -218,7 +383,7 @@ describe('OnboardingView', () => { }); it('does not show quit and reopen immediately after clicking screen recording button', async () => { - setupPermissions(true); + setupPermissions(true, true); render(); await act(async () => {}); @@ -228,7 +393,6 @@ describe('OnboardingView', () => { ); }); - // Should NOT show quit & reopen until tcc grant is detected expect(screen.queryByRole('button', { name: /quit.*reopen/i })).toBeNull(); }); @@ -236,6 +400,7 @@ describe('OnboardingView', () => { let tccGranted = false; invoke.mockImplementation(async (cmd: string) => { if (cmd === 'check_accessibility_permission') return true; + if (cmd === 'check_input_monitoring_permission') return true; if (cmd === 'check_screen_recording_permission') return false; if (cmd === 'request_screen_recording_access') return; if (cmd === 'open_screen_recording_settings') return; @@ -251,14 +416,12 @@ describe('OnboardingView', () => { ); }); - // First poll: still not granted await act(async () => { await vi.advanceTimersByTimeAsync(500); }); expect(screen.queryByRole('button', { name: /quit.*reopen/i })).toBeNull(); - // Grant it tccGranted = true; await act(async () => { await vi.advanceTimersByTimeAsync(500); @@ -272,6 +435,7 @@ describe('OnboardingView', () => { it('shows quit and reopen after screen recording tcc grant is detected', async () => { invoke.mockImplementation(async (cmd: string) => { if (cmd === 'check_accessibility_permission') return true; + if (cmd === 'check_input_monitoring_permission') return true; if (cmd === 'check_screen_recording_permission') return false; if (cmd === 'request_screen_recording_access') return; if (cmd === 'open_screen_recording_settings') return; @@ -299,6 +463,7 @@ describe('OnboardingView', () => { it('clicking quit and reopen invokes quit_and_relaunch', async () => { invoke.mockImplementation(async (cmd: string) => { if (cmd === 'check_accessibility_permission') return true; + if (cmd === 'check_input_monitoring_permission') return true; if (cmd === 'check_screen_recording_permission') return false; if (cmd === 'request_screen_recording_access') return; if (cmd === 'open_screen_recording_settings') return; @@ -325,39 +490,52 @@ describe('OnboardingView', () => { expect(invoke).toHaveBeenCalledWith('quit_and_relaunch'); }); - it('shows screen recording step info', async () => { - setupPermissions(true); - render(); - await act(async () => {}); + // ─── Unmount cleanup ────────────────────────────────────────────────────── - expect(screen.getByText('Screen Recording')).toBeInTheDocument(); - }); + it('does not emit console.error when unmounted during accessibility polling', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - it('shows both steps regardless of current active step', async () => { setupPermissions(false); - render(); + const { unmount } = render(); await act(async () => {}); - expect(screen.getByText('Accessibility')).toBeInTheDocument(); - expect(screen.getByText('Screen Recording')).toBeInTheDocument(); + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /grant accessibility/i }), + ); + }); + + act(() => unmount()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(errorSpy).not.toHaveBeenCalled(); + errorSpy.mockRestore(); }); - it('does not emit console.error when unmounted during accessibility polling', async () => { + it('does not emit console.error when unmounted during input monitoring polling', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - setupPermissions(false); + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'check_accessibility_permission') return true; + if (cmd === 'check_input_monitoring_permission') return false; + if (cmd === 'request_input_monitoring_access') return; + if (cmd === 'open_input_monitoring_settings') return; + }); + const { unmount } = render(); await act(async () => {}); await act(async () => { fireEvent.click( - screen.getByRole('button', { name: /grant accessibility/i }), + screen.getByRole('button', { name: /grant input monitoring/i }), ); }); act(() => unmount()); - // Timer ticks after unmount must not trigger React state-update warnings. await act(async () => { await vi.advanceTimersByTimeAsync(1000); }); @@ -371,6 +549,7 @@ describe('OnboardingView', () => { invoke.mockImplementation(async (cmd: string) => { if (cmd === 'check_accessibility_permission') return true; + if (cmd === 'check_input_monitoring_permission') return true; if (cmd === 'check_screen_recording_permission') return false; if (cmd === 'request_screen_recording_access') return; if (cmd === 'open_screen_recording_settings') return; @@ -396,6 +575,8 @@ describe('OnboardingView', () => { errorSpy.mockRestore(); }); + // ─── CTAButton hover ─────────────────────────────────────────────────────── + it('hovering the CTA button applies brightness filter when enabled', async () => { setupPermissions(false); render(); @@ -403,8 +584,6 @@ describe('OnboardingView', () => { const btn = screen.getByRole('button', { name: /grant accessibility/i }); fireEvent.mouseEnter(btn); - // The button is not disabled so hovered=true applies brightness(1.1). - // Verify the element is still present and interactive (no errors thrown). expect(btn).toBeInTheDocument(); fireEvent.mouseLeave(btn); expect(btn).toBeInTheDocument(); @@ -421,12 +600,10 @@ describe('OnboardingView', () => { ); }); - // Button is now disabled/polling const btn = screen.getByRole('button', { name: /checking|grant accessibility/i, }); expect(btn).toBeDisabled(); - // mouseEnter on a disabled button must not toggle hovered state fireEvent.mouseEnter(btn); expect(btn).toBeDisabled(); fireEvent.mouseLeave(btn); @@ -434,11 +611,9 @@ describe('OnboardingView', () => { }); // ─── Defensive guard coverage ───────────────────────────────────────────── - // The following tests exercise the early-return branches that protect against - // stale state updates and concurrent invocations. These branches cannot be - // reached through the happy-path tests because the invoke mock resolves - // synchronously; here we use deferred promises to keep invocations in-flight - // long enough to trigger each guard. + // Tests below exercise the early-return branches that protect against stale + // state updates and concurrent invocations. Deferred promises keep invocations + // in-flight long enough to trigger each guard before resolving them. it('ignores initial accessibility check result when component unmounts mid-flight', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -464,6 +639,33 @@ describe('OnboardingView', () => { errorSpy.mockRestore(); }); + it('ignores initial input monitoring check result when component unmounts mid-flight', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let resolveIm!: (v: boolean) => void; + invoke.mockImplementation((cmd: string) => { + if (cmd === 'check_accessibility_permission') + return Promise.resolve(true); + if (cmd === 'check_input_monitoring_permission') + return new Promise((r) => { + resolveIm = r; + }); // hangs + return Promise.resolve(); + }); + + const { unmount } = render(); + // ax check resolves true; im check is now in-flight. + await act(async () => {}); + + act(() => unmount()); // mountedRef → false + + await act(async () => { + resolveIm(true); // then-handler fires; guard returns early + }); + + expect(errorSpy).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + it('ax in-flight guard prevents concurrent permission checks', async () => { let pollCallCount = 0; let resolveFirstPoll!: (v: boolean) => void; @@ -496,7 +698,7 @@ describe('OnboardingView', () => { }); // Only one poll call (initial was count=1, first poll was count=2; second - // tick was blocked — no count=3). + // tick was blocked, no count=3). expect(pollCallCount).toBe(2); await act(async () => { @@ -504,6 +706,45 @@ describe('OnboardingView', () => { }); }); + it('im in-flight guard prevents concurrent permission checks', async () => { + let imCallCount = 0; + let resolveFirstPoll!: (v: boolean) => void; + invoke.mockImplementation((cmd: string) => { + if (cmd === 'check_accessibility_permission') + return Promise.resolve(true); + if (cmd === 'check_input_monitoring_permission') { + imCallCount++; + if (imCallCount === 1) return Promise.resolve(false); // initial mount check + return new Promise((r) => { + resolveFirstPoll = r; + }); // poll hangs + } + if (cmd === 'request_input_monitoring_access') return Promise.resolve(); + if (cmd === 'open_input_monitoring_settings') return Promise.resolve(); + return Promise.resolve(); + }); + + render(); + await act(async () => {}); // ax + initial im check done + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /grant input monitoring/i }), + ); + }); + + act(() => { + vi.advanceTimersByTime(500); // first tick: in-flight + vi.advanceTimersByTime(500); // second tick: guard blocks it + }); + + expect(imCallCount).toBe(2); // initial check + one poll; second tick blocked + + await act(async () => { + resolveFirstPoll(false); + }); + }); + it('ignores ax poll result when component unmounts during in-flight check', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); let callCount = 0; @@ -529,14 +770,10 @@ describe('OnboardingView', () => { ); }); - // Fire one tick so the poll invoke is in-flight (hanging). act(() => vi.advanceTimersByTime(500)); - // Unmount while the invoke is still pending; this clears the interval but - // the in-flight promise is still alive. act(() => unmount()); - // Resolving the promise must not trigger a React state update. await act(async () => { resolvePoll(true); }); @@ -545,12 +782,90 @@ describe('OnboardingView', () => { errorSpy.mockRestore(); }); + it('ignores im poll result when component unmounts during in-flight check', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let imCallCount = 0; + let resolvePoll!: (v: boolean) => void; + invoke.mockImplementation((cmd: string) => { + if (cmd === 'check_accessibility_permission') + return Promise.resolve(true); + if (cmd === 'check_input_monitoring_permission') { + imCallCount++; + if (imCallCount === 1) return Promise.resolve(false); // initial mount check + return new Promise((r) => { + resolvePoll = r; + }); + } + if (cmd === 'request_input_monitoring_access') return Promise.resolve(); + if (cmd === 'open_input_monitoring_settings') return Promise.resolve(); + return Promise.resolve(); + }); + + const { unmount } = render(); + await act(async () => {}); // ax + initial im check done + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /grant input monitoring/i }), + ); + }); + + act(() => vi.advanceTimersByTime(500)); // poll fires, invoke hangs + + act(() => unmount()); // clears interval; in-flight promise still alive + + await act(async () => { + resolvePoll(true); + }); + + expect(errorSpy).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + it('ignores input monitoring handler when component unmounts during open-settings call', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let resolveOpen!: (v?: unknown) => void; + invoke.mockImplementation((cmd: string) => { + if (cmd === 'check_accessibility_permission') + return Promise.resolve(true); + if (cmd === 'check_input_monitoring_permission') + return Promise.resolve(false); + if (cmd === 'request_input_monitoring_access') return Promise.resolve(); + if (cmd === 'open_input_monitoring_settings') + return new Promise((r) => { + resolveOpen = r; + }); // hangs + return Promise.resolve(); + }); + + const { unmount } = render(); + await act(async () => {}); // ax granted; im check done (false) + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /grant input monitoring/i }), + ); + }); + // handler is suspended on open_input_monitoring_settings (resolveOpen set) + + act(() => unmount()); // mountedRef → false + + await act(async () => { + resolveOpen(); // mountedRef guard fires; returns early + }); + + expect(errorSpy).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + it('ignores screen recording handler when component unmounts during open-settings call', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); let resolveOpen!: (v?: unknown) => void; invoke.mockImplementation((cmd: string) => { if (cmd === 'check_accessibility_permission') return Promise.resolve(true); + if (cmd === 'check_input_monitoring_permission') + return Promise.resolve(true); if (cmd === 'request_screen_recording_access') return Promise.resolve(); if (cmd === 'open_screen_recording_settings') return new Promise((r) => { @@ -562,11 +877,8 @@ describe('OnboardingView', () => { }); const { unmount } = render(); - await act(async () => {}); // accessibility granted + await act(async () => {}); // ax + im both granted - // Flush microtasks so the handler advances past the first await - // (request_screen_recording_access resolves) and suspends on the second - // (open_screen_recording_settings hangs), setting resolveOpen. await act(async () => { fireEvent.click( screen.getByRole('button', { name: /open screen recording settings/i }), @@ -576,7 +888,7 @@ describe('OnboardingView', () => { act(() => unmount()); // mountedRef → false await act(async () => { - resolveOpen(); // mountedRef guard at line 225 fires; returns early + resolveOpen(); // mountedRef guard fires; returns early }); expect(errorSpy).not.toHaveBeenCalled(); @@ -589,6 +901,8 @@ describe('OnboardingView', () => { invoke.mockImplementation((cmd: string) => { if (cmd === 'check_accessibility_permission') return Promise.resolve(true); + if (cmd === 'check_input_monitoring_permission') + return Promise.resolve(true); if (cmd === 'request_screen_recording_access') return Promise.resolve(); if (cmd === 'open_screen_recording_settings') return Promise.resolve(); if (cmd === 'check_screen_recording_tcc_granted') { @@ -627,6 +941,8 @@ describe('OnboardingView', () => { invoke.mockImplementation((cmd: string) => { if (cmd === 'check_accessibility_permission') return Promise.resolve(true); + if (cmd === 'check_input_monitoring_permission') + return Promise.resolve(true); if (cmd === 'request_screen_recording_access') return Promise.resolve(); if (cmd === 'open_screen_recording_settings') return Promise.resolve(); if (cmd === 'check_screen_recording_tcc_granted') @@ -650,7 +966,7 @@ describe('OnboardingView', () => { act(() => unmount()); // clears interval; in-flight promise still alive await act(async () => { - resolvePoll(true); // mountedRef guard at line 234 fires; returns early + resolvePoll(true); }); expect(errorSpy).not.toHaveBeenCalled(); diff --git a/src/view/onboarding/PermissionsStep.tsx b/src/view/onboarding/PermissionsStep.tsx index 406c780..a71cbcd 100644 --- a/src/view/onboarding/PermissionsStep.tsx +++ b/src/view/onboarding/PermissionsStep.tsx @@ -8,6 +8,7 @@ import thukiLogo from '../../../src-tauri/icons/128x128.png'; const POLL_INTERVAL_MS = 500; type AccessibilityStatus = 'pending' | 'requesting' | 'granted'; +type InputMonitoringStatus = 'idle' | 'requesting' | 'polling' | 'granted'; type ScreenRecordingStatus = 'idle' | 'polling' | 'granted'; /** Inline macOS-style keyboard key chip for showing hotkey symbols. */ @@ -80,7 +81,29 @@ const KeyboardIcon = () => ( ); -/** Screen/camera icon for step 2. */ +/** Eye/monitoring icon for the Input Monitoring step. */ +const MonitoringIcon = ({ active }: { active: boolean }) => ( + +); + +/** Screen/camera icon for step 3. */ const ScreenIcon = ({ active }: { active: boolean }) => ( ( /** * Onboarding screen shown at first launch when required macOS permissions - * (Accessibility and Screen Recording) have not yet been granted. - * - * Follows a sequential flow: Accessibility first (polls until granted, - * no restart needed), then Screen Recording (registers app via - * CGRequestScreenCaptureAccess, polls TCC until granted, then prompts - * quit+reopen since macOS requires a restart for the permission to take effect). + * have not yet been granted. * - * Visual direction: Warm Ambient — dark base with a warm orange radial glow. - * The outer container is transparent so the rounded panel corners are visible - * against the macOS desktop. + * Follows a sequential flow: + * 1. Accessibility: polls until granted, no restart needed + * 2. Input Monitoring: requests via IOHIDRequestAccess, polls until granted, + * no restart needed (CGEventTap begins receiving cross-app events immediately) + * 3. Screen Recording: registers app via CGRequestScreenCaptureAccess, polls + * TCC until granted, then prompts quit+reopen (macOS requires restart) */ export function PermissionsStep() { const [accessibilityStatus, setAccessibilityStatus] = useState('pending'); + const [inputMonitoringStatus, setInputMonitoringStatus] = + useState('idle'); const [screenRecordingStatus, setScreenRecordingStatus] = useState('idle'); + const axPollRef = useRef | null>(null); + const imPollRef = useRef | null>(null); const screenPollRef = useRef | null>(null); + // Guards that prevent a new poll tick from firing while a previous invoke // call is still in-flight. Without these, a slow IPC response (> POLL_INTERVAL_MS) // could queue multiple concurrent permission checks. const axInFlightRef = useRef(false); + const imInFlightRef = useRef(false); const screenInFlightRef = useRef(false); + // Prevents state updates from resolving in-flight invocations after unmount. const mountedRef = useRef(true); @@ -172,6 +200,13 @@ export function PermissionsStep() { } }, []); + const stopImPolling = useCallback(() => { + if (imPollRef.current !== null) { + clearInterval(imPollRef.current); + imPollRef.current = null; + } + }, []); + const stopScreenPolling = useCallback(() => { if (screenPollRef.current !== null) { clearInterval(screenPollRef.current); @@ -180,7 +215,7 @@ export function PermissionsStep() { }, []); // On mount: check whether Accessibility is already granted so we can skip - // step 1 and show step 2 immediately. + // step 1, and if so check Input Monitoring so we can skip step 2. useEffect(() => { // Reset on every mount so that a remount after unmount gets a fresh guard. mountedRef.current = true; @@ -188,14 +223,23 @@ export function PermissionsStep() { if (!mountedRef.current) return; if (granted) { setAccessibilityStatus('granted'); + void invoke('check_input_monitoring_permission').then( + (imGranted) => { + if (!mountedRef.current) return; + if (imGranted) { + setInputMonitoringStatus('granted'); + } + }, + ); } }); return () => { mountedRef.current = false; stopAxPolling(); + stopImPolling(); stopScreenPolling(); }; - }, [stopAxPolling, stopScreenPolling]); + }, [stopAxPolling, stopImPolling, stopScreenPolling]); const handleGrantAccessibility = useCallback(async () => { setAccessibilityStatus('requesting'); @@ -216,6 +260,32 @@ export function PermissionsStep() { }, POLL_INTERVAL_MS); }, [stopAxPolling]); + const handleGrantInputMonitoring = useCallback(async () => { + setInputMonitoringStatus('requesting'); + // Register Thuki in TCC and trigger the system permission dialog. + await invoke('request_input_monitoring_access'); + // Also open System Settings in case the user previously denied the dialog. + await invoke('open_input_monitoring_settings'); + if (!mountedRef.current) return; + setInputMonitoringStatus('polling'); + imPollRef.current = setInterval(async () => { + if (imInFlightRef.current) return; + imInFlightRef.current = true; + try { + const granted = await invoke( + 'check_input_monitoring_permission', + ); + if (!mountedRef.current) return; + if (granted) { + stopImPolling(); + setInputMonitoringStatus('granted'); + } + } finally { + imInFlightRef.current = false; + } + }, POLL_INTERVAL_MS); + }, [stopImPolling]); + const handleOpenScreenRecording = useCallback(async () => { // Register Thuki in TCC (adds it to the Screen Recording list) then open // System Settings directly so the user can toggle it on without hunting. @@ -248,6 +318,10 @@ export function PermissionsStep() { const accessibilityGranted = accessibilityStatus === 'granted'; const isAxRequesting = accessibilityStatus === 'requesting'; + const imGranted = inputMonitoringStatus === 'granted'; + const isImRequesting = + inputMonitoringStatus === 'requesting' || + inputMonitoringStatus === 'polling'; const isScreenPolling = screenRecordingStatus === 'polling'; const screenGranted = screenRecordingStatus === 'granted'; @@ -295,7 +369,7 @@ export function PermissionsStep() { }} /> - {/* Logo mark + title — drag region so the user can reposition the + {/* Logo mark + title, drag region so the user can reposition the onboarding window when it overlaps System Settings. */}
- {/* Step 2: Screen Recording */} - + {/* Step 2: Input Monitoring */} +
- + {imGranted ? ( + + ) : ( + + )}
+ Input Monitoring +
+
+ Required for hotkey to work when another app is focused +
+
+ {imGranted && ( +
+ Granted +
+ )} +
+ + {/* Step 3: Screen Recording */} + +
+ +
+
+
Screen Recording
@@ -432,8 +561,22 @@ export function PermissionsStep() { )} - {/* Step 2 CTAs: Open Settings (with polling) + Quit & Reopen */} - {accessibilityGranted && ( + {/* Step 2 CTA: Grant Input Monitoring */} + {accessibilityGranted && !imGranted && ( + + {isImRequesting ? 'Checking...' : 'Grant Input Monitoring Access'} + + )} + + {/* Step 3 CTAs: Open Settings (with polling) + Quit & Reopen */} + {accessibilityGranted && imGranted && ( <> {!screenGranted && ( Date: Tue, 7 Apr 2026 22:33:56 -0500 Subject: [PATCH 2/3] chore: remove debug eprintln logs from activator and lib Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Logan Nguyen --- src-tauri/src/activator.rs | 3 --- src-tauri/src/lib.rs | 17 +---------------- 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/src-tauri/src/activator.rs b/src-tauri/src/activator.rs index 9773fc9..51a76b3 100644 --- a/src-tauri/src/activator.rs +++ b/src-tauri/src/activator.rs @@ -316,13 +316,10 @@ where // Check specific bitmask for the Control key state let is_press = flags.contains(CGEventFlags::CGEventFlagControl); - eprintln!("thuki: [activator] Ctrl key event: is_press={is_press}"); let mut s = cb_state.lock().unwrap(); if evaluate_activation(&mut s, is_press) { - eprintln!("thuki: [activator] double-tap detected — invoking activation handler"); cb_on_activation(); - eprintln!("thuki: [activator] activation handler returned"); } CallbackResult::Keep diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3ee1424..94053fd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -210,7 +210,6 @@ fn monitor_info_fallback() -> (f64, f64, f64, f64) { #[cfg(target_os = "macos")] fn show_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationContext) { let already_visible = OVERLAY_INTENDED_VISIBLE.swap(true, Ordering::SeqCst); - eprintln!("thuki: [show_overlay] already_visible={already_visible}"); if already_visible { return; } @@ -279,16 +278,13 @@ fn show_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationCo match app_handle.get_webview_panel("main") { Ok(panel) => { - eprintln!("thuki: [show_overlay] calling show_and_make_key"); panel.show_and_make_key(); - eprintln!("thuki: [show_overlay] emitting show event"); emit_overlay_visibility( app_handle, OVERLAY_VISIBILITY_SHOW, selected_text, window_anchor, ); - eprintln!("thuki: [show_overlay] done"); } Err(e) => { eprintln!("thuki: [show_overlay] get_webview_panel FAILED: {e:?}"); @@ -329,9 +325,7 @@ fn show_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationCo /// Uses an atomic flag as the single source of truth for intended visibility, /// which avoids race conditions with the native panel state during animations. fn toggle_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationContext) { - let vis = OVERLAY_INTENDED_VISIBLE.load(Ordering::SeqCst); - eprintln!("thuki: [toggle_overlay] called, OVERLAY_INTENDED_VISIBLE={vis}"); - if vis { + if OVERLAY_INTENDED_VISIBLE.load(Ordering::SeqCst) { request_overlay_hide(app_handle); } else { show_overlay(app_handle, ctx); @@ -373,7 +367,6 @@ fn set_window_frame(app_handle: tauri::AppHandle, x: f64, y: f64, width: f64, he /// completes its exit animation and hides the native window. #[tauri::command] fn notify_overlay_hidden() { - eprintln!("thuki: [notify_overlay_hidden] called"); OVERLAY_INTENDED_VISIBLE.store(false, Ordering::SeqCst); } @@ -699,15 +692,7 @@ pub fn run() { // CFRunLoop and silently prevents all future key events from // being delivered to the activator. std::thread::spawn(move || { - let t0 = std::time::Instant::now(); - eprintln!( - "thuki: [activator] context capture started (is_visible={is_visible})" - ); let ctx = crate::context::capture_activation_context(is_visible); - eprintln!( - "thuki: [activator] context capture done in {:?}", - t0.elapsed() - ); let _ = handle.run_on_main_thread(move || toggle_overlay(&handle2, ctx)); }); }); From a28a69cd24b11838b2a904de1211423c26a141ca Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Tue, 7 Apr 2026 22:54:04 -0500 Subject: [PATCH 3/3] fix: correct stale Session-level reference in is_input_monitoring_granted doc The doc comment incorrectly stated the permission was required for a CGEventTap at Session level. The tap runs at HID level since the fix in this branch. Updated to reflect the actual implementation. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Logan Nguyen --- src-tauri/src/permissions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/permissions.rs b/src-tauri/src/permissions.rs index d193cce..f3969d3 100644 --- a/src-tauri/src/permissions.rs +++ b/src-tauri/src/permissions.rs @@ -65,7 +65,7 @@ pub fn is_accessibility_granted() -> bool { /// Returns whether the process currently has Input Monitoring permission. /// /// Input Monitoring (`kTCCServiceListenEvent`) is required for a CGEventTap at -/// Session level to receive keyboard events from other applications. Without it, +/// HID level to receive keyboard events from other applications. Without it, /// the tap only sees events generated within the Thuki process itself, making /// the double-tap hotkey invisible when the user's focus is elsewhere. ///