Skip to content

Commit f9a411b

Browse files
committed
fix(capture/macos): refresh display bounds on system wake
CGDisplayRegisterReconfigurationCallback covers monitor plug/unplug and resolution changes during normal operation, but in clamshell- disconnect → lid-open transitions the callback either doesn't fire (no actual reconfigure event reaches us during sleep) or fires too early to be useful, leaving lan-mouse's cached `self.bounds` matching whatever the display layout was when the lid closed. Symptom: after opening the lid on a Mac that had been clamshelled to an external monitor and then disconnected, the cursor behaves as if constrained to the now-absent external display's resolution. Subscribe to the IOKit power-management notifications via `IORegisterForSystemPower` and attach the resulting CFRunLoopSource to the same run loop that owns the event tap. On `kIOMessageSystemHasPoweredOn` (post-wake), post the same `ProducerEvent::DisplayReconfigured` the existing handler consumes, so `update_bounds()` runs against the live display set. Sleep-pending messages get acked with `IOAllowPowerChange` so we don't stall the kernel's 30-second client-wait timeout. Cleanup paths added: deregister + destroy notification port + drop the boxed refcon when the run loop exits.
1 parent 148c1ed commit f9a411b

1 file changed

Lines changed: 128 additions & 2 deletions

File tree

input-capture/src/macos.rs

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use core_foundation::{
55
base::{CFRelease, TCFType, kCFAllocatorDefault},
66
date::CFTimeInterval,
77
number::{CFBooleanRef, kCFBooleanTrue},
8-
runloop::{CFRunLoop, CFRunLoopSource, kCFRunLoopCommonModes},
8+
runloop::{CFRunLoop, CFRunLoopSource, CFRunLoopSourceRef, kCFRunLoopCommonModes},
99
string::{CFStringCreateWithCString, CFStringRef, kCFStringEncodingUTF8},
1010
};
1111
use core_graphics::{
@@ -664,14 +664,47 @@ fn event_tap_thread(
664664
// callback runs on this thread's CFRunLoop. Box-leak the sender
665665
// so the C side has a stable user_info pointer; reclaim it after
666666
// the run loop exits.
667-
let display_user_info = Box::into_raw(Box::new(display_notify_tx)) as *mut c_void;
667+
let display_user_info = Box::into_raw(Box::new(display_notify_tx.clone())) as *mut c_void;
668668
unsafe {
669669
CGDisplayRegisterReconfigurationCallback(
670670
display_reconfiguration_callback,
671671
display_user_info,
672672
);
673673
}
674674

675+
// Also subscribe to system-power events so we recover from
676+
// sleep/wake, where the Quartz reconfigure callback may not
677+
// fire (or fires before our run loop is processing again, e.g.
678+
// clamshell-disconnect → lid-open). On wake we send the same
679+
// DisplayReconfigured event the existing handler consumes, so
680+
// bounds get refreshed for free.
681+
let mut power_notifier_object: u32 = 0;
682+
let mut power_notification_port: *mut c_void = std::ptr::null_mut();
683+
let power_ctx = Box::into_raw(Box::new(PowerCtx {
684+
sender: display_notify_tx,
685+
root_port: 0,
686+
}));
687+
let power_root_port = unsafe {
688+
let port = IORegisterForSystemPower(
689+
power_ctx as *mut c_void,
690+
&mut power_notification_port,
691+
power_callback,
692+
&mut power_notifier_object,
693+
);
694+
// Stash the root port for the callback's IOAllowPowerChange
695+
// ack — we couldn't know it at Box-construction time because
696+
// it's the registration's return value.
697+
(*power_ctx).root_port = port;
698+
if !power_notification_port.is_null() {
699+
let src_ref = IONotificationPortGetRunLoopSource(power_notification_port);
700+
if !src_ref.is_null() {
701+
let src = CFRunLoopSource::wrap_under_get_rule(src_ref);
702+
CFRunLoop::get_current().add_source(&src, kCFRunLoopCommonModes);
703+
}
704+
}
705+
port
706+
};
707+
675708
log::debug!("running CFRunLoop...");
676709
CFRunLoop::run_current();
677710
log::debug!("event tap thread exiting!...");
@@ -683,6 +716,15 @@ fn event_tap_thread(
683716
drop(Box::from_raw(
684717
display_user_info as *mut Sender<ProducerEvent>,
685718
));
719+
720+
if power_notifier_object != 0 {
721+
let _ = IODeregisterForSystemPower(&mut power_notifier_object);
722+
}
723+
if !power_notification_port.is_null() {
724+
IONotificationPortDestroy(power_notification_port);
725+
}
726+
let _ = power_root_port;
727+
drop(Box::from_raw(power_ctx));
686728
}
687729

688730
let _ = exit.send(());
@@ -719,6 +761,65 @@ fn is_screen_locked() -> bool {
719761
locked
720762
}
721763

764+
/// Refcon for the IOKit system-power callback. Bundles the channel
765+
/// sender (so the callback can post `DisplayReconfigured` on wake)
766+
/// and the `io_connect_t` root port (so the callback can ack
767+
/// sleep-related messages with `IOAllowPowerChange`). Built on the
768+
/// event-tap thread, used only by the callback on the same thread —
769+
/// never crosses thread boundaries, so no Send/Sync needed.
770+
struct PowerCtx {
771+
sender: Sender<ProducerEvent>,
772+
root_port: u32,
773+
}
774+
775+
/// IOKit system-power callback. Fires for every power-management
776+
/// transition (CanSleep, WillSleep, WillPowerOn, HasPoweredOn).
777+
/// We only care about `kIOMessageSystemHasPoweredOn` (post-wake);
778+
/// for the sleep-pending messages we just ack so the kernel doesn't
779+
/// hold the system in its "waiting for clients" state for the full
780+
/// 30-second timeout.
781+
extern "C" fn power_callback(
782+
refcon: *mut c_void,
783+
_service: u32,
784+
msg_type: u32,
785+
msg_arg: *mut c_void,
786+
) {
787+
const K_IO_MESSAGE_CAN_SYSTEM_SLEEP: u32 = 0xE000_0270;
788+
const K_IO_MESSAGE_SYSTEM_WILL_SLEEP: u32 = 0xE000_0280;
789+
const K_IO_MESSAGE_SYSTEM_HAS_POWERED_ON: u32 = 0xE000_0300;
790+
791+
if refcon.is_null() {
792+
return;
793+
}
794+
// SAFETY: `refcon` is `Box::into_raw(Box::new(PowerCtx))` owned by
795+
// `event_tap_thread`; valid until the run loop exits and the box
796+
// is reclaimed. The callback only fires while the run loop runs
797+
// on that thread, so the box is live here.
798+
let ctx = unsafe { &*(refcon as *const PowerCtx) };
799+
match msg_type {
800+
K_IO_MESSAGE_CAN_SYSTEM_SLEEP | K_IO_MESSAGE_SYSTEM_WILL_SLEEP => {
801+
// Ack so the OS doesn't stall on its 30s default timeout.
802+
// `msg_arg` carries the notification ID (an `intptr_t`);
803+
// pass it through verbatim.
804+
unsafe {
805+
IOAllowPowerChange(ctx.root_port, msg_arg as isize);
806+
}
807+
}
808+
K_IO_MESSAGE_SYSTEM_HAS_POWERED_ON => {
809+
// Bounce a DisplayReconfigured into the producer so
810+
// `update_bounds()` runs. Covers the case where Quartz's
811+
// own reconfigure callback didn't fire (or fired during
812+
// the sleep window) — e.g. clamshell-disconnect →
813+
// lid-open transitions.
814+
log::info!("system woke from sleep; refreshing display bounds");
815+
if let Err(e) = ctx.sender.blocking_send(ProducerEvent::DisplayReconfigured) {
816+
log::warn!("failed to post wake → DisplayReconfigured: {e}");
817+
}
818+
}
819+
_ => {}
820+
}
821+
}
822+
722823
/// Quartz display-reconfiguration callback. Fires twice per change:
723824
/// once with `kCGDisplayBeginConfigurationFlag` set (BEFORE the
724825
/// change is applied — the bounds are still stale at this point),
@@ -993,6 +1094,31 @@ extern "C" {
9931094
) -> CGError;
9941095
}
9951096

1097+
#[link(name = "IOKit", kind = "framework")]
1098+
extern "C" {
1099+
/// Register the calling process for system-power notifications.
1100+
/// Returns the `io_connect_t` root power port (used later in
1101+
/// `IOAllowPowerChange` to ack sleep-related messages) and writes
1102+
/// the notification port + an `io_object_t` notifier through the
1103+
/// out-pointers. The returned notification port carries a
1104+
/// CFRunLoopSource we attach to this thread's run loop so the
1105+
/// callback fires inline with the existing event-tap loop.
1106+
fn IORegisterForSystemPower(
1107+
refcon: *mut c_void,
1108+
port_ref: *mut *mut c_void,
1109+
callback: extern "C" fn(*mut c_void, u32, u32, *mut c_void),
1110+
notifier: *mut u32,
1111+
) -> u32;
1112+
fn IODeregisterForSystemPower(notifier: *mut u32) -> i32;
1113+
fn IONotificationPortGetRunLoopSource(notify: *mut c_void) -> CFRunLoopSourceRef;
1114+
fn IONotificationPortDestroy(notify: *mut c_void);
1115+
/// Ack a kIOMessageCanSystemSleep / kIOMessageSystemWillSleep so
1116+
/// the OS doesn't stall on its 30s default timeout waiting for us.
1117+
/// Required even when we have no objection — silence is treated as
1118+
/// "still thinking" by the kernel.
1119+
fn IOAllowPowerChange(kernel_port: u32, notification_id: isize) -> i32;
1120+
}
1121+
9961122
#[link(name = "ApplicationServices", kind = "framework")]
9971123
extern "C" {
9981124
fn AXIsProcessTrusted() -> bool;

0 commit comments

Comments
 (0)