macos: refresh display bounds on reconfiguration

InputCaptureState fetches the active-display bounds once in new()
via CGDisplay::active_displays(), and crossed() / start_capture()
both consult those frozen bounds for every barrier check and
cursor warp. Plug in a monitor, change resolution, or rearrange
displays in System Settings and the bounds go stale immediately:
the cursor either stops crossing at the new edge or warps to the
old edge coordinates.

Register a Quartz CGDisplayRegisterReconfigurationCallback on the
event-tap thread's CFRunLoop and route a new
ProducerEvent::DisplayReconfigured into the existing producer
channel. The producer task re-runs update_bounds() on the live
state, so subsequent barrier checks use the current geometry.

The callback fires twice per change — once with the
kCGDisplayBeginConfigurationFlag (BEFORE the bounds update) and
once after — we filter the begin-phase out and only refresh on
the post-change notification. The callback registration is
removed and the leaked sender Box is reclaimed when the run loop
exits, so create/destroy cycles don't leak channel senders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jon Kinney
2026-04-27 14:12:05 -05:00
committed by Ferdinand Schober
parent bb1cc805c1
commit e863cdb801

View File

@@ -67,6 +67,7 @@ enum ProducerEvent {
Destroy(Position), Destroy(Position),
Grab(Position), Grab(Position),
EventTapDisabled, EventTapDisabled,
DisplayReconfigured,
} }
impl InputCaptureState { impl InputCaptureState {
@@ -187,6 +188,20 @@ impl InputCaptureState {
} }
return Err(CaptureError::EventTapDisabled); return Err(CaptureError::EventTapDisabled);
} }
ProducerEvent::DisplayReconfigured => {
// The macOS display configuration changed — a monitor
// was plugged in/out, the resolution changed, the
// arrangement was rearranged, etc. Re-fetch the
// active-display bounds so barrier crossings and the
// cursor-warp on capture-start use the current
// geometry instead of whatever was true at process
// start.
if let Err(e) = self.update_bounds() {
log::warn!("failed to refresh display bounds: {e}");
} else {
log::info!("display reconfigured: {:?}", self.bounds);
}
}
}; };
Ok(()) Ok(())
} }
@@ -563,6 +578,9 @@ fn event_tap_thread(
ready: std::sync::mpsc::Sender<Result<CFRunLoop, MacosCaptureCreationError>>, ready: std::sync::mpsc::Sender<Result<CFRunLoop, MacosCaptureCreationError>>,
exit: oneshot::Sender<()>, exit: oneshot::Sender<()>,
) { ) {
// Clone now: create_event_tap consumes notify_tx into its closure.
let display_notify_tx = notify_tx.clone();
let _tap = match create_event_tap(client_state, notify_tx, event_tx) { let _tap = match create_event_tap(client_state, notify_tx, event_tx) {
Err(e) => { Err(e) => {
ready.send(Err(e)).expect("channel closed"); ready.send(Err(e)).expect("channel closed");
@@ -574,13 +592,70 @@ fn event_tap_thread(
tap tap
} }
}; };
// Register a Quartz display-reconfiguration callback so the
// capture state's bounds get refreshed when the user plugs in a
// monitor, changes resolution, or rearranges displays. The
// callback runs on this thread's CFRunLoop. Box-leak the sender
// so the C side has a stable user_info pointer; reclaim it after
// the run loop exits.
let display_user_info =
Box::into_raw(Box::new(display_notify_tx)) as *mut c_void;
unsafe {
CGDisplayRegisterReconfigurationCallback(
display_reconfiguration_callback,
display_user_info,
);
}
log::debug!("running CFRunLoop..."); log::debug!("running CFRunLoop...");
CFRunLoop::run_current(); CFRunLoop::run_current();
log::debug!("event tap thread exiting!..."); log::debug!("event tap thread exiting!...");
unsafe {
CGDisplayRemoveReconfigurationCallback(
display_reconfiguration_callback,
display_user_info,
);
// Reclaim the leaked sender Box so we don't leak a tokio
// channel sender on every capture create/destroy cycle.
drop(Box::from_raw(
display_user_info as *mut Sender<ProducerEvent>,
));
}
let _ = exit.send(()); let _ = exit.send(());
} }
/// Quartz display-reconfiguration callback. Fires twice per change:
/// once with `kCGDisplayBeginConfigurationFlag` set (BEFORE the
/// change is applied — the bounds are still stale at this point),
/// then again afterwards with the actual change flags (Add, Remove,
/// Mode, DesktopShapeChanged, etc.). Skip the begin phase; on the
/// real notification, kick the producer task to refresh bounds.
extern "C" fn display_reconfiguration_callback(
_display: u32,
flags: u32,
user_info: *mut c_void,
) {
const K_CG_DISPLAY_BEGIN_CONFIGURATION_FLAG: u32 = 1 << 0;
if flags & K_CG_DISPLAY_BEGIN_CONFIGURATION_FLAG != 0 {
return;
}
if user_info.is_null() {
return;
}
// SAFETY: user_info is a Box::into_raw of Sender<ProducerEvent>
// owned by `event_tap_thread`. It's valid for the lifetime of
// that thread; the registration is removed before the box is
// freed. The callback only fires while the run loop is running
// on that thread, so we know the box is live here.
let sender = unsafe { &*(user_info as *const Sender<ProducerEvent>) };
if let Err(e) = sender.blocking_send(ProducerEvent::DisplayReconfigured) {
log::warn!("failed to notify display reconfiguration: {e}");
}
}
pub struct MacOSInputCapture { pub struct MacOSInputCapture {
event_rx: Receiver<(Position, CaptureEvent)>, event_rx: Receiver<(Position, CaptureEvent)>,
notify_tx: Sender<ProducerEvent>, notify_tx: Sender<ProducerEvent>,
@@ -754,6 +829,18 @@ extern "C" {
/// argument is a `CFMachPortRef`; we pass the raw pointer so we /// argument is a `CFMachPortRef`; we pass the raw pointer so we
/// can store it as `usize` for cross-thread sharing. /// can store it as `usize` for cross-thread sharing.
fn CGEventTapEnable(tap: *mut c_void, enable: bool); fn CGEventTapEnable(tap: *mut c_void, enable: bool);
/// Register a callback invoked when the display configuration
/// changes (monitor add/remove, resolution change, mirror,
/// rearrange, etc). See Quartz Display Services Reference.
fn CGDisplayRegisterReconfigurationCallback(
callback: extern "C" fn(u32, u32, *mut c_void),
user_info: *mut c_void,
) -> CGError;
fn CGDisplayRemoveReconfigurationCallback(
callback: extern "C" fn(u32, u32, *mut c_void),
user_info: *mut c_void,
) -> CGError;
} }
#[link(name = "ApplicationServices", kind = "framework")] #[link(name = "ApplicationServices", kind = "framework")]