agentmux_cef\wrr/win_event.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase B.9.1 — `SetWinEventHook` installation + callback.
5//
6// One install at startup, one teardown at shutdown. The hook
7// runs WINEVENT_OUTOFCONTEXT (callback fires on the calling
8// thread of the hooking process — no DLL injection, no extra
9// thread). We filter to the host's own PID via the `idProcess`
10// parameter so we don't see events from other processes.
11//
12// The callback fires synchronously per OS event. It:
13// 1. Filters out non-window object events (OBJID != OBJID_WINDOW
14// or CHILDID != CHILDID_SELF).
15// 2. Reads the HWND's class name; bails if not in
16// `classify::is_app_class`.
17// 3. Maps the event ID to a launcher_ipc::report_hwnd_*
18// function and dispatches.
19//
20// The `report_hwnd_*` functions on the launcher_ipc side are
21// non-blocking sync APIs (UnboundedSender → drain task) so this
22// callback returns quickly even under burst load.
23
24use std::sync::{Arc, OnceLock};
25
26use windows_sys::Win32::Foundation::{HWND, RECT};
27use windows_sys::Win32::Graphics::Gdi::{
28 EnumDisplayMonitors, GetMonitorInfoW, HDC, HMONITOR, MONITORINFO,
29};
30use windows_sys::Win32::UI::Accessibility::{
31 SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK,
32};
33use windows_sys::Win32::UI::WindowsAndMessaging::{
34 GetClassNameW, GetWindowRect, GetWindowTextW, GetWindowThreadProcessId, IsIconic,
35 IsWindowVisible, CHILDID_SELF, EVENT_OBJECT_CREATE, EVENT_OBJECT_DESTROY, EVENT_OBJECT_HIDE,
36 EVENT_OBJECT_LOCATIONCHANGE, EVENT_OBJECT_SHOW, EVENT_SYSTEM_FOREGROUND,
37 EVENT_SYSTEM_MINIMIZEEND, EVENT_SYSTEM_MINIMIZESTART, OBJID_WINDOW, WINEVENT_OUTOFCONTEXT,
38};
39
40use crate::launcher_ipc;
41use crate::state::AppState;
42use crate::wrr::{classify, position_debounce};
43
44/// Phase B.9.1 — handle to host AppState for the static
45/// `SetWinEventHook` callback (the OS API takes a fixed-shape
46/// `extern "system" fn`, so AppState has to be reachable through
47/// a static). Set once by `install_hooks(state)`. The callback
48/// reads `pending_window_creations` to supply `label_hint` on
49/// `EVENT_OBJECT_CREATE`, eliminating the launcher-side
50/// "pending HWND with no matching mirror" race.
51fn app_state() -> &'static OnceLock<Arc<AppState>> {
52 static S: OnceLock<Arc<AppState>> = OnceLock::new();
53 &S
54}
55
56
57/// Phase B.9.1 — handles to the installed hooks. Held in a
58/// `OnceLock<Mutex<Option<...>>>` so install_hooks is idempotent
59/// (no-op on second call) and uninstall can drop them on shutdown.
60fn hook_handles() -> &'static std::sync::Mutex<Vec<HookHandle>> {
61 static H: OnceLock<std::sync::Mutex<Vec<HookHandle>>> = OnceLock::new();
62 H.get_or_init(|| std::sync::Mutex::new(Vec::new()))
63}
64
65#[derive(Debug)]
66struct HookHandle(HWINEVENTHOOK);
67
68// SAFETY: HWINEVENTHOOK is a HANDLE (opaque pointer). Send + Sync
69// are needed to store it in a static Mutex; the Win32 API doesn't
70// document any thread-affinity for the handle itself (Unhook can
71// be called from any thread).
72unsafe impl Send for HookHandle {}
73unsafe impl Sync for HookHandle {}
74
75/// Phase B.9.1 — install the WRR hooks. Idempotent (logs and
76/// returns if hooks are already installed). Must be called from a
77/// thread that owns a Win32 message pump — the host's CEF UI
78/// thread does, so we install from there at startup.
79///
80/// Also enumerates the current monitor topology and reports it
81/// once. WM_DISPLAYCHANGE wiring for mid-session topology updates
82/// is a B.9.2 follow-up.
83///
84/// `state` is stashed in a static `OnceLock` so the static callback
85/// can read `pending_window_creations` to supply `label_hint`.
86pub fn install_hooks(state: Arc<AppState>) {
87 if app_state().set(state).is_err() {
88 tracing::debug!("[wrr] install_hooks called twice — already installed (state set)");
89 }
90 let mut handles = match hook_handles().lock() {
91 Ok(g) => g,
92 Err(poisoned) => poisoned.into_inner(),
93 };
94 if !handles.is_empty() {
95 tracing::debug!("[wrr] install_hooks called twice — already installed");
96 return;
97 }
98
99 let pid = std::process::id();
100
101 // Hook range 1: object create / destroy / show / hide.
102 // EVENT_OBJECT_CREATE..=EVENT_OBJECT_HIDE happens to be a
103 // contiguous range (0x8000..=0x8003); install one hook with
104 // bracketing endpoints.
105 let h1 = install_one(EVENT_OBJECT_CREATE, EVENT_OBJECT_HIDE, pid);
106 if let Some(h) = h1 {
107 handles.push(HookHandle(h));
108 }
109
110 // Hook range 2: foreground change. Discrete event; installed
111 // as a degenerate range (start == end).
112 let h2 = install_one(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, pid);
113 if let Some(h) = h2 {
114 handles.push(HookHandle(h));
115 }
116
117 // Hook range 3: minimize start / end.
118 let h3 = install_one(EVENT_SYSTEM_MINIMIZESTART, EVENT_SYSTEM_MINIMIZEEND, pid);
119 if let Some(h) = h3 {
120 handles.push(HookHandle(h));
121 }
122
123 // Hook range 4: location change (debounced — see
124 // position_debounce.rs). Discrete event.
125 let h4 = install_one(
126 EVENT_OBJECT_LOCATIONCHANGE,
127 EVENT_OBJECT_LOCATIONCHANGE,
128 pid,
129 );
130 if let Some(h) = h4 {
131 handles.push(HookHandle(h));
132 }
133
134 let installed_count = handles.len();
135 drop(handles);
136
137 tracing::info!(
138 "[wrr] installed {}/{} WinEventHook range(s) for pid={}",
139 installed_count, 4, pid
140 );
141
142 // Enumerate the initial monitor topology and report it once.
143 // Without this, `state.monitors` stays empty in the launcher
144 // and `OffMonitor` drift is suppressed (per the design doc).
145 let monitors = enumerate_monitors();
146 if !monitors.is_empty() {
147 launcher_ipc::report_monitor_topology_changed(monitors.clone());
148 tracing::info!("[wrr] reported initial monitor topology: {} monitor(s)", monitors.len());
149 } else {
150 tracing::warn!("[wrr] initial monitor enumeration returned 0 monitors — OffMonitor drift will be suppressed");
151 }
152}
153
154fn install_one(event_min: u32, event_max: u32, pid: u32) -> Option<HWINEVENTHOOK> {
155 unsafe {
156 let h = SetWinEventHook(
157 event_min,
158 event_max,
159 std::ptr::null_mut(),
160 Some(win_event_callback),
161 pid,
162 0, // idThread = 0 means all threads in the process
163 WINEVENT_OUTOFCONTEXT,
164 );
165 if h.is_null() {
166 tracing::error!(
167 "[wrr] SetWinEventHook failed for range {:#x}..={:#x}",
168 event_min,
169 event_max
170 );
171 None
172 } else {
173 Some(h)
174 }
175 }
176}
177
178/// Phase B.9.1 — uninstall the hooks. Called from host shutdown.
179pub fn uninstall_hooks() {
180 let mut handles = match hook_handles().lock() {
181 Ok(g) => g,
182 Err(poisoned) => poisoned.into_inner(),
183 };
184 let mut count = 0;
185 for h in handles.drain(..) {
186 unsafe {
187 if UnhookWinEvent(h.0) != 0 {
188 count += 1;
189 }
190 }
191 }
192 if count > 0 {
193 tracing::info!("[wrr] unhooked {} WinEventHook range(s)", count);
194 }
195}
196
197/// Phase B.9.1 — hook callback. Runs OUT-OF-CONTEXT (on the
198/// hooking thread, posted via the message pump). Must not block
199/// for long: we only do quick HWND-property reads and dispatch
200/// non-blocking sync IPC reports.
201unsafe extern "system" fn win_event_callback(
202 _hook: HWINEVENTHOOK,
203 event: u32,
204 hwnd: HWND,
205 id_object: i32,
206 id_child: i32,
207 _id_event_thread: u32,
208 _dwms_event_time: u32,
209) {
210 // 1. Only OBJID_WINDOW with CHILDID_SELF — events for child
211 // controls and accessibility objects are noise.
212 if id_object != OBJID_WINDOW || id_child != CHILDID_SELF as i32 {
213 return;
214 }
215 if hwnd.is_null() {
216 return;
217 }
218
219 // 2. Confirm the HWND belongs to our process (defense-in-
220 // depth — `idProcess` filter on the hook should already
221 // restrict this, but the OS occasionally bubbles events for
222 // ancestor / desktop windows).
223 let mut hwnd_pid: u32 = 0;
224 GetWindowThreadProcessId(hwnd, &mut hwnd_pid);
225 if hwnd_pid != std::process::id() {
226 return;
227 }
228
229 let raw_hwnd = hwnd as u64;
230
231 // Phase B.9.1 — diagnostic. INFO-level for B.9.1 smoke;
232 // dial back to debug! once we've confirmed the chain is
233 // wired end-to-end. Volume is bounded by the OBJID_WINDOW
234 // + CHILDID_SELF + idProcess filters above; for normal use
235 // this fires at ~5-20/sec during user activity.
236 tracing::info!(
237 target: "wrr",
238 "[wrr] callback event=0x{:x} hwnd={:#x}",
239 event, raw_hwnd
240 );
241
242 // 3. Per-event dispatch. Reads HWND properties as needed.
243 match event {
244 EVENT_OBJECT_CREATE => {
245 let class = read_class_name(hwnd);
246 // Cheap filter at the hook so we don't IPC every
247 // tooltip / IME / message-only window.
248 if !classify::is_app_class(&class) || classify::is_explicitly_excluded(&class) {
249 return;
250 }
251 let title = read_window_text(hwnd);
252 // Phase B.9.1 diagnostic — log app-class creates so
253 // smoke can correlate with launcher-side reception.
254 tracing::info!(
255 target: "wrr",
256 "[wrr] EVENT_OBJECT_CREATE app-class hwnd={:#x} class={} title={:?}",
257 raw_hwnd, class, title
258 );
259 // PR — drop back-of-queue label_hint optimization.
260 //
261 // The original B.9.1 design peeked the back of
262 // `pending_window_creations` to label this OS-level
263 // WM_CREATE event. That assumed at most one create in
264 // flight at any time. When users click "open new window"
265 // multiple times in succession, multiple pending entries
266 // queue up, and back-of-queue returns the LATEST label
267 // for EVERY in-flight WM_CREATE — mislabeling every HWND
268 // that arrives before its corresponding `on_after_created`.
269 //
270 // Worse: the launcher's drain-on-WindowOpened fallback in
271 // `handle_report_window_opened` (launcher reducer.rs) only
272 // drains pending HWNDs whose `label_hint.is_none()`. A
273 // wrong hint actively HIJACKS the fallback — the launcher
274 // sees `label_hint=Some(WRONG)`, doesn't match any
275 // existing mirror, stashes a wrong-labeled pending entry,
276 // and the fallback never runs because of the is_none()
277 // filter. Result: aliased mirror entries (multiple labels
278 // pointing to the same HWND in launcher state, or vice
279 // versa), `HwndWithoutBrowser` drift errors, and the
280 // user-visible bug "InstancePanel grows but no window
281 // appears; closing one window collapses multiple panel
282 // entries."
283 //
284 // Always passing None routes EVERY HWND through the
285 // launcher's drain-on-WindowOpened fallback, which matches
286 // the most-recent unlinked pending HWND when the
287 // authoritative `ReportWindowOpened` arrives from
288 // `on_after_created`. Same path pool windows already used.
289 //
290 // See `docs/retro/wrr-label-hint-race-2026-05-02.md` for
291 // full diagnosis from the 0.33.589 smoke session.
292 launcher_ipc::report_hwnd_opened(raw_hwnd, class, title, None);
293
294 // Fire synthetic position + visibility events after
295 // create so the reducer has a complete state for this
296 // HWND right away (LOCATIONCHANGE wouldn't fire on a
297 // window that lands at its final position immediately).
298 if let Some(rect) = read_window_rect(hwnd) {
299 if position_debounce::should_emit(raw_hwnd) {
300 launcher_ipc::report_hwnd_position_changed(raw_hwnd, rect);
301 }
302 }
303 let visible = IsWindowVisible(hwnd) != 0;
304 launcher_ipc::report_hwnd_visibility_changed(raw_hwnd, visible);
305 let iconic = IsIconic(hwnd) != 0;
306 launcher_ipc::report_hwnd_iconic_changed(raw_hwnd, iconic);
307 }
308 EVENT_OBJECT_DESTROY => {
309 // No class-name filter on destroy — the HWND may be
310 // mid-teardown so its class is unreliable. The
311 // launcher reducer handles "destroy of an unknown
312 // HWND" as a no-op via pending_hwnds + windows
313 // membership check.
314 position_debounce::forget(raw_hwnd);
315 launcher_ipc::report_hwnd_destroyed(raw_hwnd);
316 }
317 EVENT_OBJECT_SHOW => {
318 let class = read_class_name(hwnd);
319 if !classify::is_app_class(&class) {
320 return;
321 }
322 launcher_ipc::report_hwnd_visibility_changed(raw_hwnd, true);
323 }
324 EVENT_OBJECT_HIDE => {
325 let class = read_class_name(hwnd);
326 if !classify::is_app_class(&class) {
327 return;
328 }
329 launcher_ipc::report_hwnd_visibility_changed(raw_hwnd, false);
330 }
331 EVENT_SYSTEM_FOREGROUND => {
332 let class = read_class_name(hwnd);
333 if !classify::is_app_class(&class) {
334 return;
335 }
336 launcher_ipc::report_hwnd_foreground_changed(raw_hwnd);
337 }
338 EVENT_SYSTEM_MINIMIZESTART => {
339 let class = read_class_name(hwnd);
340 if !classify::is_app_class(&class) {
341 return;
342 }
343 launcher_ipc::report_hwnd_iconic_changed(raw_hwnd, true);
344 }
345 EVENT_SYSTEM_MINIMIZEEND => {
346 let class = read_class_name(hwnd);
347 if !classify::is_app_class(&class) {
348 return;
349 }
350 launcher_ipc::report_hwnd_iconic_changed(raw_hwnd, false);
351 }
352 EVENT_OBJECT_LOCATIONCHANGE => {
353 // Heavy event during drags — debounce per HWND.
354 if !position_debounce::should_emit(raw_hwnd) {
355 return;
356 }
357 let class = read_class_name(hwnd);
358 if !classify::is_app_class(&class) {
359 return;
360 }
361 if let Some(rect) = read_window_rect(hwnd) {
362 launcher_ipc::report_hwnd_position_changed(raw_hwnd, rect);
363 }
364 }
365 _ => {}
366 }
367}
368
369/// Read the Win32 class name into an owned `String`.
370unsafe fn read_class_name(hwnd: HWND) -> String {
371 let mut buf = [0u16; 256];
372 let n = GetClassNameW(hwnd, buf.as_mut_ptr(), buf.len() as i32);
373 if n <= 0 {
374 return String::new();
375 }
376 String::from_utf16_lossy(&buf[..n as usize])
377}
378
379/// Read the window's text (title) into an owned `String`. Empty
380/// string if no title.
381unsafe fn read_window_text(hwnd: HWND) -> String {
382 let mut buf = [0u16; 512];
383 let n = GetWindowTextW(hwnd, buf.as_mut_ptr(), buf.len() as i32);
384 if n <= 0 {
385 return String::new();
386 }
387 String::from_utf16_lossy(&buf[..n as usize])
388}
389
390/// Read the window's screen-coordinate rectangle. `None` if the
391/// API call fails (window torn down between our event and the
392/// read — common during destroy).
393unsafe fn read_window_rect(hwnd: HWND) -> Option<agentmux_common::ipc::Rect> {
394 let mut r: RECT = std::mem::zeroed();
395 if GetWindowRect(hwnd, &mut r) == 0 {
396 return None;
397 }
398 Some(agentmux_common::ipc::Rect {
399 left: r.left,
400 top: r.top,
401 right: r.right,
402 bottom: r.bottom,
403 })
404}
405
406/// Phase B.9.1 — initial monitor enumeration. Called once from
407/// install_hooks. Mid-session topology changes (a follow-up
408/// concern for B.9.2) would re-enumerate via WM_DISPLAYCHANGE.
409fn enumerate_monitors() -> Vec<agentmux_common::ipc::Rect> {
410 use std::cell::RefCell;
411 thread_local! {
412 // Per-thread accumulator so the callback below can push
413 // into it without locking. EnumDisplayMonitors is
414 // synchronous — we drain after it returns.
415 static MONITORS: RefCell<Vec<agentmux_common::ipc::Rect>> =
416 RefCell::new(Vec::new());
417 }
418
419 unsafe extern "system" fn enum_proc(
420 h: HMONITOR,
421 _hdc: HDC,
422 _rect: *mut RECT,
423 _data: windows_sys::Win32::Foundation::LPARAM,
424 ) -> windows_sys::Win32::Foundation::BOOL {
425 let mut info: MONITORINFO = std::mem::zeroed();
426 info.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
427 if GetMonitorInfoW(h, &mut info) != 0 {
428 MONITORS.with(|m| {
429 m.borrow_mut().push(agentmux_common::ipc::Rect {
430 left: info.rcMonitor.left,
431 top: info.rcMonitor.top,
432 right: info.rcMonitor.right,
433 bottom: info.rcMonitor.bottom,
434 });
435 });
436 }
437 1 // continue enumeration
438 }
439
440 MONITORS.with(|m| m.borrow_mut().clear());
441 unsafe {
442 EnumDisplayMonitors(std::ptr::null_mut(), std::ptr::null(), Some(enum_proc), 0);
443 }
444 MONITORS.with(|m| m.borrow().clone())
445}