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..51a76b3 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; @@ -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() @@ -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, } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 39e3324..94053fd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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; } @@ -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); } @@ -400,14 +402,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 +/// 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. +/// +/// 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 && (