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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ 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
- **Three permissions required** — Accessibility (CGEventTap creation), Input Monitoring (cross-app key delivery), Screen Recording (/screen command)
- **Two permissions required** — Accessibility (CGEventTap creation), Screen Recording (/screen command)

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

Expand Down
6 changes: 0 additions & 6 deletions src-tauri/src/activator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,12 +247,6 @@ fn try_initialize_tap<F>(is_active: &Arc<AtomicBool>, on_activation: &Arc<F>) ->
where
F: Fn() + Send + Sync + 'static,
{
let im_granted = crate::permissions::is_input_monitoring_granted();
eprintln!(
"thuki: [activator] Input Monitoring permission: granted={im_granted} \
(cross-app hotkey requires this)"
);

let state = Arc::new(Mutex::new(ActivationState {
last_trigger: None,
is_pressed: false,
Expand Down
52 changes: 26 additions & 26 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -402,9 +402,8 @@ fn notify_frontend_ready(app_handle: tauri::AppHandle, db: tauri::State<history:
// screen again on the next launch.
let ax = permissions::is_accessibility_granted();
let sr = permissions::is_screen_recording_granted();
let im = permissions::is_input_monitoring_granted();

if !ax || !sr || !im {
if !ax || !sr {
let _ = onboarding::set_stage(&conn, &onboarding::OnboardingStage::Permissions);
show_onboarding_window(&app_handle, onboarding::OnboardingStage::Permissions);
return;
Expand Down Expand Up @@ -673,29 +672,36 @@ pub fn run() {
.build(app)?;

// ── Activation listener (macOS only) ─────────────────────────
// Only start the event tap when Accessibility is already granted.
// Creating a CGEventTap without permission triggers a native macOS
// popup; deferring until after onboarding (and the quit+reopen for
// Screen Recording) avoids that redundant dialog entirely.
#[cfg(target_os = "macos")]
{
let app_handle = app.handle().clone();
let activator = activator::OverlayActivator::new();
activator.start(move || {
// Skip AX + clipboard when hiding — no context needed and
// simulating Cmd+C against Thuki's own WebView would produce
// a macOS alert sound.
let is_visible = OVERLAY_INTENDED_VISIBLE.load(Ordering::SeqCst);
let handle = app_handle.clone();
let handle2 = app_handle.clone();
// Dispatch context capture to a dedicated thread so the event
// tap callback returns immediately. AX attribute lookups and
// clipboard simulation can block for seconds (macOS AX default
// timeout is ~6 s) when the focused app does not implement the
// accessibility protocol. Blocking the tap callback freezes the
// CFRunLoop and silently prevents all future key events from
// being delivered to the activator.
std::thread::spawn(move || {
let ctx = crate::context::capture_activation_context(is_visible);
let _ = handle.run_on_main_thread(move || toggle_overlay(&handle2, ctx));
if permissions::is_accessibility_granted() {
activator.start(move || {
// Skip AX + clipboard when hiding — no context needed and
// simulating Cmd+C against Thuki's own WebView would produce
// a macOS alert sound.
let is_visible = OVERLAY_INTENDED_VISIBLE.load(Ordering::SeqCst);
let handle = app_handle.clone();
let handle2 = app_handle.clone();
// Dispatch context capture to a dedicated thread so the event
// tap callback returns immediately. AX attribute lookups and
// clipboard simulation can block for seconds (macOS AX default
// timeout is ~6 s) when the focused app does not implement the
// accessibility protocol. Blocking the tap callback freezes the
// CFRunLoop and silently prevents all future key events from
// being delivered to the activator.
std::thread::spawn(move || {
let ctx = crate::context::capture_activation_context(is_visible);
let _ =
handle.run_on_main_thread(move || toggle_overlay(&handle2, ctx));
});
});
});
}
app.manage(activator);
}

Expand Down Expand Up @@ -762,12 +768,6 @@ pub fn run() {
#[cfg(not(coverage))]
permissions::open_accessibility_settings,
#[cfg(not(coverage))]
permissions::check_input_monitoring_permission,
#[cfg(not(coverage))]
permissions::request_input_monitoring_access,
#[cfg(not(coverage))]
permissions::open_input_monitoring_settings,
#[cfg(not(coverage))]
permissions::check_screen_recording_permission,
#[cfg(not(coverage))]
permissions::open_screen_recording_settings,
Expand Down
106 changes: 11 additions & 95 deletions src-tauri/src/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,11 @@

/// Returns `true` when at least one required permission has not been granted.
///
/// 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
/// Both Accessibility (hotkey listener) and Screen Recording (/screen command)
/// must be granted for Thuki to function fully. If either is missing the
/// onboarding screen is shown instead of the normal overlay.
pub fn needs_onboarding(accessibility: bool, screen_recording: bool) -> bool {
!accessibility || !screen_recording
}

// ─── macOS Permission Checks ─────────────────────────────────────────────────
Expand All @@ -36,47 +29,13 @@ 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))]
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
Expand All @@ -102,44 +61,6 @@ 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.
///
Expand Down Expand Up @@ -244,27 +165,22 @@ mod tests {
use super::*;

#[test]
fn needs_onboarding_false_when_all_granted() {
assert!(!needs_onboarding(true, true, true));
fn needs_onboarding_false_when_both_granted() {
assert!(!needs_onboarding(true, true));
}

#[test]
fn needs_onboarding_true_when_accessibility_missing() {
assert!(needs_onboarding(false, true, true));
assert!(needs_onboarding(false, true));
}

#[test]
fn needs_onboarding_true_when_screen_recording_missing() {
assert!(needs_onboarding(true, false, true));
}

#[test]
fn needs_onboarding_true_when_input_monitoring_missing() {
assert!(needs_onboarding(true, true, false));
assert!(needs_onboarding(true, false));
}

#[test]
fn needs_onboarding_true_when_all_missing() {
assert!(needs_onboarding(false, false, false));
fn needs_onboarding_true_when_both_missing() {
assert!(needs_onboarding(false, false));
}
}
Loading
Loading