Skip to content

Commit ba1df94

Browse files
authored
fix: remove Input Monitoring and suppress native permission popups (#68)
* refactor: remove Input Monitoring permission requirement from onboarding Simplify the onboarding permission flow from 3 steps (Accessibility, Input Monitoring, Screen Recording) to 2 steps (Accessibility, Screen Recording). Input Monitoring checks and IOKit FFI bindings are removed from permissions.rs and PermissionsStep.tsx. The CGEventTap at HID level receives cross-app keyboard events without needing a TCC entry. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * fix: defer CGEventTap creation until Accessibility is granted Gate activator.start() behind is_accessibility_granted() so the HID-level event tap is only created when permission already exists. This prevents macOS from showing the native Accessibility popup during onboarding; the activator starts cleanly on the post-Screen Recording quit+reopen when both permissions are guaranteed. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * style: replace em dashes with appropriate punctuation Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> --------- Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
1 parent f409e08 commit ba1df94

File tree

6 files changed

+127
-676
lines changed

6 files changed

+127
-676
lines changed

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ Never commit files generated by superpowers skills (design specs, implementation
110110

111111
- **macOS only** — uses NSPanel, Core Graphics event taps, macOS Control key
112112
- **Privacy-first** — Ollama runs locally; Docker sandbox drops all capabilities and isolates network
113-
- **Three permissions required** — Accessibility (CGEventTap creation), Input Monitoring (cross-app key delivery), Screen Recording (/screen command)
113+
- **Two permissions required** — Accessibility (CGEventTap creation), Screen Recording (/screen command)
114114

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

src-tauri/src/activator.rs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -247,12 +247,6 @@ fn try_initialize_tap<F>(is_active: &Arc<AtomicBool>, on_activation: &Arc<F>) ->
247247
where
248248
F: Fn() + Send + Sync + 'static,
249249
{
250-
let im_granted = crate::permissions::is_input_monitoring_granted();
251-
eprintln!(
252-
"thuki: [activator] Input Monitoring permission: granted={im_granted} \
253-
(cross-app hotkey requires this)"
254-
);
255-
256250
let state = Arc::new(Mutex::new(ActivationState {
257251
last_trigger: None,
258252
is_pressed: false,

src-tauri/src/lib.rs

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -402,9 +402,8 @@ fn notify_frontend_ready(app_handle: tauri::AppHandle, db: tauri::State<history:
402402
// screen again on the next launch.
403403
let ax = permissions::is_accessibility_granted();
404404
let sr = permissions::is_screen_recording_granted();
405-
let im = permissions::is_input_monitoring_granted();
406405

407-
if !ax || !sr || !im {
406+
if !ax || !sr {
408407
let _ = onboarding::set_stage(&conn, &onboarding::OnboardingStage::Permissions);
409408
show_onboarding_window(&app_handle, onboarding::OnboardingStage::Permissions);
410409
return;
@@ -673,29 +672,36 @@ pub fn run() {
673672
.build(app)?;
674673

675674
// ── Activation listener (macOS only) ─────────────────────────
675+
// Only start the event tap when Accessibility is already granted.
676+
// Creating a CGEventTap without permission triggers a native macOS
677+
// popup; deferring until after onboarding (and the quit+reopen for
678+
// Screen Recording) avoids that redundant dialog entirely.
676679
#[cfg(target_os = "macos")]
677680
{
678681
let app_handle = app.handle().clone();
679682
let activator = activator::OverlayActivator::new();
680-
activator.start(move || {
681-
// Skip AX + clipboard when hiding — no context needed and
682-
// simulating Cmd+C against Thuki's own WebView would produce
683-
// a macOS alert sound.
684-
let is_visible = OVERLAY_INTENDED_VISIBLE.load(Ordering::SeqCst);
685-
let handle = app_handle.clone();
686-
let handle2 = app_handle.clone();
687-
// Dispatch context capture to a dedicated thread so the event
688-
// tap callback returns immediately. AX attribute lookups and
689-
// clipboard simulation can block for seconds (macOS AX default
690-
// timeout is ~6 s) when the focused app does not implement the
691-
// accessibility protocol. Blocking the tap callback freezes the
692-
// CFRunLoop and silently prevents all future key events from
693-
// being delivered to the activator.
694-
std::thread::spawn(move || {
695-
let ctx = crate::context::capture_activation_context(is_visible);
696-
let _ = handle.run_on_main_thread(move || toggle_overlay(&handle2, ctx));
683+
if permissions::is_accessibility_granted() {
684+
activator.start(move || {
685+
// Skip AX + clipboard when hiding — no context needed and
686+
// simulating Cmd+C against Thuki's own WebView would produce
687+
// a macOS alert sound.
688+
let is_visible = OVERLAY_INTENDED_VISIBLE.load(Ordering::SeqCst);
689+
let handle = app_handle.clone();
690+
let handle2 = app_handle.clone();
691+
// Dispatch context capture to a dedicated thread so the event
692+
// tap callback returns immediately. AX attribute lookups and
693+
// clipboard simulation can block for seconds (macOS AX default
694+
// timeout is ~6 s) when the focused app does not implement the
695+
// accessibility protocol. Blocking the tap callback freezes the
696+
// CFRunLoop and silently prevents all future key events from
697+
// being delivered to the activator.
698+
std::thread::spawn(move || {
699+
let ctx = crate::context::capture_activation_context(is_visible);
700+
let _ =
701+
handle.run_on_main_thread(move || toggle_overlay(&handle2, ctx));
702+
});
697703
});
698-
});
704+
}
699705
app.manage(activator);
700706
}
701707

@@ -762,12 +768,6 @@ pub fn run() {
762768
#[cfg(not(coverage))]
763769
permissions::open_accessibility_settings,
764770
#[cfg(not(coverage))]
765-
permissions::check_input_monitoring_permission,
766-
#[cfg(not(coverage))]
767-
permissions::request_input_monitoring_access,
768-
#[cfg(not(coverage))]
769-
permissions::open_input_monitoring_settings,
770-
#[cfg(not(coverage))]
771771
permissions::check_screen_recording_permission,
772772
#[cfg(not(coverage))]
773773
permissions::open_screen_recording_settings,

src-tauri/src/permissions.rs

Lines changed: 11 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,11 @@
1414

1515
/// Returns `true` when at least one required permission has not been granted.
1616
///
17-
/// All three permissions must be granted for Thuki to function fully:
18-
/// - Accessibility: required to create the CGEventTap
19-
/// - Input Monitoring: required for the tap to receive events from other apps
20-
/// - Screen Recording: required for the /screen command
21-
///
22-
/// If any is missing the onboarding screen is shown instead of the normal overlay.
23-
pub fn needs_onboarding(
24-
accessibility: bool,
25-
screen_recording: bool,
26-
input_monitoring: bool,
27-
) -> bool {
28-
!accessibility || !screen_recording || !input_monitoring
17+
/// Both Accessibility (hotkey listener) and Screen Recording (/screen command)
18+
/// must be granted for Thuki to function fully. If either is missing the
19+
/// onboarding screen is shown instead of the normal overlay.
20+
pub fn needs_onboarding(accessibility: bool, screen_recording: bool) -> bool {
21+
!accessibility || !screen_recording
2922
}
3023

3124
// ─── macOS Permission Checks ─────────────────────────────────────────────────
@@ -36,47 +29,13 @@ extern "C" {
3629
fn AXIsProcessTrusted() -> bool;
3730
}
3831

39-
/// IOKit constants for Input Monitoring permission checks.
40-
#[cfg(target_os = "macos")]
41-
const IOHID_REQUEST_TYPE_LISTEN_EVENT: u32 = 1;
42-
#[cfg(target_os = "macos")]
43-
const IOHID_ACCESS_TYPE_GRANTED: u32 = 1;
44-
45-
#[cfg(target_os = "macos")]
46-
#[link(name = "IOKit", kind = "framework")]
47-
extern "C" {
48-
/// Checks whether the process has Input Monitoring access.
49-
/// Returns kIOHIDAccessTypeGranted (1) if granted, 0 if not determined,
50-
/// 2 if denied.
51-
fn IOHIDCheckAccess(request_type: u32) -> u32;
52-
53-
/// Requests Input Monitoring access, showing the system permission dialog
54-
/// on first call. Returns true if access was granted.
55-
fn IOHIDRequestAccess(request_type: u32) -> bool;
56-
}
57-
5832
/// Returns whether the process currently has Accessibility permission.
5933
#[cfg(target_os = "macos")]
6034
#[cfg_attr(coverage_nightly, coverage(off))]
6135
pub fn is_accessibility_granted() -> bool {
6236
unsafe { AXIsProcessTrusted() }
6337
}
6438

65-
/// Returns whether the process currently has Input Monitoring permission.
66-
///
67-
/// Input Monitoring (`kTCCServiceListenEvent`) is required for a CGEventTap at
68-
/// HID level to receive keyboard events from other applications. Without it,
69-
/// the tap only sees events generated within the Thuki process itself, making
70-
/// the double-tap hotkey invisible when the user's focus is elsewhere.
71-
///
72-
/// Unlike Screen Recording, Input Monitoring does not require a process restart
73-
/// after being granted; the CGEventTap immediately begins receiving cross-app events.
74-
#[cfg(target_os = "macos")]
75-
#[cfg_attr(coverage_nightly, coverage(off))]
76-
pub fn is_input_monitoring_granted() -> bool {
77-
unsafe { IOHIDCheckAccess(IOHID_REQUEST_TYPE_LISTEN_EVENT) == IOHID_ACCESS_TYPE_GRANTED }
78-
}
79-
8039
/// Returns whether the process currently has Screen Recording permission.
8140
///
8241
/// Uses `CGPreflightScreenCaptureAccess`, which only returns `true` after
@@ -102,44 +61,6 @@ pub fn check_accessibility_permission() -> bool {
10261
is_accessibility_granted()
10362
}
10463

105-
/// Returns whether Input Monitoring permission has been granted.
106-
#[tauri::command]
107-
#[cfg(target_os = "macos")]
108-
#[cfg_attr(coverage_nightly, coverage(off))]
109-
pub fn check_input_monitoring_permission() -> bool {
110-
is_input_monitoring_granted()
111-
}
112-
113-
/// Triggers the macOS Input Monitoring permission dialog.
114-
///
115-
/// `IOHIDRequestAccess` registers the app in TCC and shows the system-level
116-
/// "X would like to monitor input events" prompt. If the user previously denied
117-
/// the permission, the dialog is skipped and the call returns false; the
118-
/// onboarding UI then directs the user to System Settings directly.
119-
#[tauri::command]
120-
#[cfg(target_os = "macos")]
121-
#[cfg_attr(coverage_nightly, coverage(off))]
122-
pub fn request_input_monitoring_access() {
123-
unsafe {
124-
IOHIDRequestAccess(IOHID_REQUEST_TYPE_LISTEN_EVENT);
125-
}
126-
}
127-
128-
/// Opens System Settings to the Input Monitoring privacy pane.
129-
#[tauri::command]
130-
#[cfg(target_os = "macos")]
131-
#[cfg_attr(coverage_nightly, coverage(off))]
132-
pub fn open_input_monitoring_settings() -> Result<(), String> {
133-
std::process::Command::new("open")
134-
.arg(
135-
"x-apple.systempreferences:com.apple.preference.security\
136-
?Privacy_ListenEvent",
137-
)
138-
.spawn()
139-
.map(|_| ())
140-
.map_err(|e| e.to_string())
141-
}
142-
14364
/// Opens System Settings to the Accessibility privacy pane so the user can
14465
/// enable the permission without encountering the native system popup.
14566
///
@@ -244,27 +165,22 @@ mod tests {
244165
use super::*;
245166

246167
#[test]
247-
fn needs_onboarding_false_when_all_granted() {
248-
assert!(!needs_onboarding(true, true, true));
168+
fn needs_onboarding_false_when_both_granted() {
169+
assert!(!needs_onboarding(true, true));
249170
}
250171

251172
#[test]
252173
fn needs_onboarding_true_when_accessibility_missing() {
253-
assert!(needs_onboarding(false, true, true));
174+
assert!(needs_onboarding(false, true));
254175
}
255176

256177
#[test]
257178
fn needs_onboarding_true_when_screen_recording_missing() {
258-
assert!(needs_onboarding(true, false, true));
259-
}
260-
261-
#[test]
262-
fn needs_onboarding_true_when_input_monitoring_missing() {
263-
assert!(needs_onboarding(true, true, false));
179+
assert!(needs_onboarding(true, false));
264180
}
265181

266182
#[test]
267-
fn needs_onboarding_true_when_all_missing() {
268-
assert!(needs_onboarding(false, false, false));
183+
fn needs_onboarding_true_when_both_missing() {
184+
assert!(needs_onboarding(false, false));
269185
}
270186
}

0 commit comments

Comments
 (0)