Files
lan-mouse/lan-mouse-gtk/src/macos_privacy.rs
Jon Kinney 36f27c28ac macos: per-pane TCC navigation and Sequoia-tolerant permission flow
On macOS the three TCC grants (Accessibility, Input Monitoring, Post
Event) live in separate Privacy panes. Before this change the
"Reenable" row sent the user to Accessibility regardless of which
grant was actually missing, and the daemon's own permission checks
re-fired the Accessibility prompt on every retry.

- lan-mouse-gtk/src/macos_privacy.rs: new module that exposes silent
  preflight checks (AXIsProcessTrusted, CGPreflightListenEventAccess,
  CGPreflightPostEventAccess), per-pane URL-scheme navigation, and
  a Once-guarded fire_initial_prompts() called from build_ui. The
  initial-prompt path only fires the Accessibility prompt if AX is
  missing and then returns; secondary registrations run only after
  AX is granted, which prevents a double Accessibility alert on
  Sequoia where Post Event is nested under Accessibility.
- Input Monitoring registration attempts CGEventTapCreate at
  kCGSessionEventTap (not kCGHIDEventTap) so a failure surfaces as
  an Input Monitoring signal rather than triggering an Accessibility
  prompt as a side effect.
- lan-mouse-gtk/src/window/imp.rs: handle_capture / handle_emulation
  switch on the missing-pane enum and navigate to the specific pane
  via x-apple.systempreferences:... URLs before re-requesting.
- lan-mouse-gtk/resources/window.ui: pill class on the Reenable
  buttons so the hover padding matches the rest of libadwaita.
- input-capture/src/macos.rs, input-emulation/src/macos.rs: make
  request_*_permission() a silent preflight (AXIsProcessTrusted /
  CGPreflightListenEventAccess / CGPreflightPostEventAccess), so the
  daemon no longer fires TCC prompts on retry — all prompting is
  owned by the GUI.
- input-capture/src/error.rs, input-emulation/src/error.rs: new
  error variants so the GUI can distinguish missing-AX from
  missing-IM / missing-PostEvent for pane routing.

Verified on macOS 15.5: first launch fires a single AX prompt;
second launch (AX granted) registers under Input Monitoring via the
session-tap attempt and requests Post Event. Sequoia auto-grants the
listen-only path via AX so the IM list may stay empty, which is the
intended OS behavior and no longer blocks capture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 02:09:32 -05:00

246 lines
9.1 KiB
Rust

#![cfg(target_os = "macos")]
//! Tiny macOS Privacy-pane helpers used by the GUI.
//!
//! Clicking "Reenable" on the capture/emulation warning row should take the
//! user to whichever Privacy pane is actually missing a grant — opening the
//! Accessibility pane when the user has already granted Accessibility (and
//! only needs Input Monitoring) is confusing and hides the real request.
use std::ffi::{c_uchar, c_void};
use std::process::Command;
use std::sync::Once;
// Apple declares `AXIsProcessTrusted` as returning `Boolean` (`unsigned char`),
// NOT C's `bool`. Rust's `bool` has a strict bit pattern (0 or 1) so binding
// a `Boolean`-returning function as `-> bool` is technically UB if Apple ever
// returns a non-canonical true value. Keep these as `c_uchar` and normalize.
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXIsProcessTrusted() -> c_uchar;
fn AXIsProcessTrustedWithOptions(options: *const c_void) -> c_uchar;
}
#[link(name = "CoreFoundation", kind = "framework")]
extern "C" {
static kCFAllocatorDefault: *const c_void;
static kCFTypeDictionaryKeyCallBacks: *const c_void;
static kCFTypeDictionaryValueCallBacks: *const c_void;
static kCFBooleanTrue: *const c_void;
fn CFDictionaryCreate(
allocator: *const c_void,
keys: *const *const c_void,
values: *const *const c_void,
num: isize,
key_callbacks: *const c_void,
value_callbacks: *const c_void,
) -> *const c_void;
fn CFRelease(cf: *const c_void);
}
// kAXTrustedCheckOptionPrompt is a CFStringRef exported from ApplicationServices.
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
static kAXTrustedCheckOptionPrompt: *const c_void;
}
#[link(name = "CoreGraphics", kind = "framework")]
extern "C" {
fn CGPreflightListenEventAccess() -> c_uchar;
fn CGRequestListenEventAccess() -> c_uchar;
fn CGPreflightPostEventAccess() -> c_uchar;
fn CGRequestPostEventAccess() -> c_uchar;
// CFMachPortRef CGEventTapCreate(
// CGEventTapLocation tap, CGEventTapPlacement place,
// CGEventTapOptions options, CGEventMask eventsOfInterest,
// CGEventTapCallBack callback, void *userInfo);
fn CGEventTapCreate(
tap: u32,
place: u32,
options: u32,
events_of_interest: u64,
callback: *const c_void,
user_info: *const c_void,
) -> *const c_void;
}
pub fn accessibility_granted() -> bool {
let raw = unsafe { AXIsProcessTrusted() };
log::debug!("AXIsProcessTrusted() = {raw}");
raw != 0
}
pub fn input_monitoring_granted() -> bool {
let raw = unsafe { CGPreflightListenEventAccess() };
log::debug!("CGPreflightListenEventAccess() = {raw}");
raw != 0
}
pub fn post_event_granted() -> bool {
let raw = unsafe { CGPreflightPostEventAccess() };
log::debug!("CGPreflightPostEventAccess() = {raw}");
raw != 0
}
pub enum CapturePane {
Accessibility,
InputMonitoring,
/// Everything is already granted; the caller should just retry.
None,
}
pub enum EmulationPane {
Accessibility,
PostEvent,
None,
}
pub fn missing_capture_pane() -> CapturePane {
if !accessibility_granted() {
CapturePane::Accessibility
} else if !input_monitoring_granted() {
CapturePane::InputMonitoring
} else {
CapturePane::None
}
}
pub fn missing_emulation_pane() -> EmulationPane {
if !accessibility_granted() {
EmulationPane::Accessibility
} else if !post_event_granted() {
EmulationPane::PostEvent
} else {
EmulationPane::None
}
}
pub fn open_accessibility_settings() {
open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility");
}
pub fn open_input_monitoring_settings() {
unsafe {
ensure_listed_in_input_monitoring();
}
open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent");
}
pub fn open_post_event_settings() {
unsafe {
CGRequestPostEventAccess();
}
open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_PostEvent");
}
/// Make sure the app appears in System Settings → Privacy → Input Monitoring.
///
/// `CGRequestListenEventAccess()` is *supposed* to register the app in the
/// list (and prompt) on first call, but in practice — particularly after a
/// `tccutil reset ListenEvent <bundle>` — it often silently no-ops and the
/// app never gets added. The reliable way to force registration is to
/// attempt a protected action: create a `CGEventTap`. If permission is
/// missing the call returns null, but the attempt itself causes TCC to add
/// the bundle to the Input Monitoring pane so the user can toggle it on.
/// If permission already exists the tap is created successfully, and we
/// tear it down immediately so it doesn't intercept events.
unsafe fn ensure_listed_in_input_monitoring() {
let req = CGRequestListenEventAccess();
log::debug!("CGRequestListenEventAccess() = {req}");
let cb = input_monitoring_noop_tap_callback as *const c_void;
// Use kCGSessionEventTap (1), NOT kCGHIDEventTap (0). The HID tap sits
// below window-server input and requires Accessibility in addition to
// Input Monitoring, so attempting it when Accessibility isn't granted
// surfaces an Accessibility prompt as a side effect — which is confusing
// on top of the real Accessibility prompt we already fire explicitly.
// The session tap requires only Input Monitoring, so its failure is a
// clean "Input Monitoring missing" signal that TCC uses to list the
// bundle under the Input Monitoring pane.
// kCGHeadInsertEventTap = 0, kCGEventTapOptionListenOnly = 1,
// mask kCGEventKeyDown = 1 << 10.
let tap = CGEventTapCreate(1, 0, 1, 1 << 10, cb, std::ptr::null());
log::debug!("CGEventTapCreate(kCGSessionEventTap) -> {tap:?}");
if !tap.is_null() {
CFRelease(tap);
}
}
extern "C" fn input_monitoring_noop_tap_callback(
_proxy: *const c_void,
_ty: u32,
event: *const c_void,
_refcon: *const c_void,
) -> *const c_void {
// Pass through unchanged. This tap is never added to a run loop, so
// in practice the callback never fires — it exists only so the tap
// can be created (and the attempt is what forces TCC registration).
event
}
fn open_url(url: &str) {
if let Err(e) = Command::new("open").arg(url).spawn() {
log::warn!("failed to open {url}: {e}");
}
}
/// One-shot, at GUI startup: if a permission is missing, fire the system
/// prompt. This is where the familiar first-launch "Lan Mouse.app would
/// like to control this computer" alert comes from. Subsequent clicks on
/// the Reenable button use URL-scheme navigation instead, so we never
/// double up alerts on retries.
///
/// Guarded with a `Once` because GApplication::activate can fire more
/// than once in a process (reactivation, window presentation) and we
/// must not re-pop the TCC alert on each activation — that looks like a
/// bug to the user.
pub fn fire_initial_prompts() {
static FIRED: Once = Once::new();
FIRED.call_once(fire_initial_prompts_inner);
}
fn fire_initial_prompts_inner() {
if !accessibility_granted() {
// When Accessibility isn't granted yet, ONLY fire the Accessibility
// prompt. Do NOT also try to register Input Monitoring or Post Event
// — those paths have been observed to surface a second Accessibility
// dialog on top of the one we fire explicitly (Post Event is part of
// the Accessibility category on modern macOS, and CGEventTap attempts
// can bail on Accessibility before they reach the Input Monitoring
// check). Once the user grants Accessibility and relaunches, this
// branch is skipped and we register the other grants cleanly below.
log::info!("firing first-launch Accessibility prompt");
unsafe {
let key = kAXTrustedCheckOptionPrompt;
let value = kCFBooleanTrue;
let options = CFDictionaryCreate(
kCFAllocatorDefault,
&key as *const _,
&value as *const _,
1,
kCFTypeDictionaryKeyCallBacks,
kCFTypeDictionaryValueCallBacks,
);
AXIsProcessTrustedWithOptions(options);
CFRelease(options);
}
return;
}
// Accessibility is granted. Attempt Input Monitoring registration
// unconditionally — even if preflight returns true — so the bundle gets
// listed in System Settings under its own identity (otherwise launches
// from a parent process that already has Input Monitoring, e.g. Terminal,
// inherit the grant but the bundle is never listed for the user to
// toggle persistently).
log::info!("ensuring Lan Mouse is listed under Input Monitoring");
unsafe {
ensure_listed_in_input_monitoring();
}
// Same for Post Event: now that Accessibility is present, this call is
// safe — it won't surface the generic Accessibility prompt.
log::info!("ensuring Lan Mouse is listed under Accessibility > Post Event");
unsafe {
CGRequestPostEventAccess();
}
}