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}