mirror of
https://github.com/feschber/lan-mouse.git
synced 2026-05-10 08:08:04 +03:00
Now that we always route Reenable clicks to the Accessibility pane on macOS 13+ (AX transitively covers Input Monitoring listen-only and Post Event, and the bundle isn't listed in the separate panes anyway), the CapturePane / EmulationPane enums and their non-Accessibility variants are dead weight. Remove them along with: - `missing_capture_pane` / `missing_emulation_pane` - `open_input_monitoring_settings` / `open_post_event_settings` - `input_monitoring_granted` / `post_event_granted` preflight wrappers - the `CGPreflightListenEventAccess` / `CGPreflightPostEventAccess` FFI declarations in lan-mouse-gtk (the daemon crates keep their own) `handle_capture` / `handle_emulation` collapse to a single helper that opens the Accessibility pane if AX is missing, otherwise just retries. `ensure_listed_in_input_monitoring` is kept because it still has a side effect on macOS 13/14, where Input Monitoring is a separately- granted category. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
216 lines
8.7 KiB
Rust
216 lines
8.7 KiB
Rust
#![cfg(target_os = "macos")]
|
|
|
|
//! Tiny macOS Privacy-pane helpers used by the GUI.
|
|
//!
|
|
//! On macOS 13+, the Accessibility grant transitively confers the
|
|
//! listen-only event-tap privilege that Input Monitoring gates and the
|
|
//! synthesize-event privilege that Post Event gates, and the bundle
|
|
//! typically isn't even listed in those separate panes. So the single
|
|
//! user-facing action for any missing-capture or missing-emulation
|
|
//! scenario is "re-toggle Accessibility" — we don't route elsewhere.
|
|
|
|
use std::ffi::{c_uchar, c_void};
|
|
use std::process::Command;
|
|
use std::sync::Once;
|
|
|
|
use gtk::glib;
|
|
|
|
// 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 CGRequestListenEventAccess() -> 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
|
|
}
|
|
|
|
|
|
/// Poll for an Accessibility grant transition. Starts a 1-second GLib
|
|
/// timer that fires `on_granted` once, the first time
|
|
/// `AXIsProcessTrusted()` returns true. A no-op if AX is already granted.
|
|
///
|
|
/// We rely on polling rather than AXObserver because the AX notification
|
|
/// API requires a trusted process to subscribe — the precondition we're
|
|
/// waiting for. This runs on the GTK main thread (via timeout_add_seconds_local).
|
|
pub fn watch_for_accessibility_grant<F>(mut on_granted: F)
|
|
where
|
|
F: FnMut() + 'static,
|
|
{
|
|
if accessibility_granted() {
|
|
return;
|
|
}
|
|
log::info!("watching for Accessibility grant");
|
|
glib::timeout_add_seconds_local(1, move || {
|
|
if accessibility_granted() {
|
|
log::info!("Accessibility granted; firing relaunch prompt");
|
|
on_granted();
|
|
glib::ControlFlow::Break
|
|
} else {
|
|
glib::ControlFlow::Continue
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn open_accessibility_settings() {
|
|
open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility");
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
}
|