agentmux_cef\browser_pane/
hwnd.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Win32 HWND-level helpers for browser panes: the `WM_SETFOCUS` redirect
5//! subclass and the focus-bypass flag.
6//!
7//! Moved out of `client.rs` during Phase 2 of the pane modularization split
8//! (see `docs/specs/SPEC_BROWSER_PANE_MODULARIZATION.md` §6). `client.rs`
9//! still uses `ALLOW_BROWSER_PANE_FOCUS_ONCE` at a distance (nothing there imports
10//! the function directly), but `install_browser_pane_focus_redirect` is the home
11//! for pane-focused Win32 subclass logic and future phases can wire it up
12//! to pane `on_after_created` / `on_load_end` without touching `client.rs`.
13//!
14//! Everything in this file is Windows-only by gating.
15
16#![cfg(target_os = "windows")]
17
18use std::sync::{Arc, Weak};
19
20/// Map of pane HWND -> original WndProc, so the subclass hook can delegate
21/// to the real handler after running its interception logic. The mutex is
22/// held only while mutating the map — hooks that read on the UI thread
23/// copy out the pointer quickly.
24static BROWSER_PANE_WNDPROCS: std::sync::LazyLock<
25    std::sync::Mutex<std::collections::HashMap<usize, isize>>,
26> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
27
28/// Per-pane context, keyed by the pane's outer HWND. Populated by
29/// `install_browser_pane_focus_redirect`. The WndProc hook uses it to emit the
30/// `browser-pane-clicked` event on `WM_LBUTTONDOWN` without needing to
31/// round-trip through CEF callbacks — only the outer HWND is keyed here;
32/// descendants walk up via `GetParent` to find their context.
33#[derive(Clone)]
34struct BrowserPaneContext {
35    state: Weak<crate::state::AppState>,
36    block_id: String,
37}
38
39static BROWSER_PANE_HWND_CONTEXT: std::sync::LazyLock<
40    std::sync::Mutex<std::collections::HashMap<usize, BrowserPaneContext>>,
41> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
42
43/// Remove every `BROWSER_PANE_HWND_CONTEXT` entry whose context refers to the given
44/// `block_id`. Called from `on_before_close_browser_pane` so the map doesn't grow
45/// unbounded as panes are opened and closed over the session. Keyed by
46/// block_id (not HWND) because the close path has the label/block_id
47/// immediately but not the HWND — by the time CEF fires on_before_close,
48/// the browser's HWND may already be invalid.
49pub fn remove_contexts_for_block(block_id: &str) {
50    if let Ok(mut map) = BROWSER_PANE_HWND_CONTEXT.lock() {
51        let before = map.len();
52        map.retain(|_hwnd, ctx| ctx.block_id != block_id);
53        let removed = before - map.len();
54        if removed > 0 {
55            tracing::info!(
56                block_id = %block_id,
57                removed = removed,
58                remaining = map.len(),
59                "[pane-hwnd] cleaned up hwnd context entries",
60            );
61        }
62    }
63}
64
65/// When `true`, the next `WM_SETFOCUS` delivered to a subclassed pane HWND
66/// is allowed through instead of being redirected back to the parent.
67///
68/// The frontend's `giveFocus()` -> `browser_pane_focus` IPC sets this flag
69/// before calling `SetFocus` on the pane, so user-initiated focus works
70/// even though Chromium's internal focus-steal on navigation is blocked.
71pub static ALLOW_BROWSER_PANE_FOCUS_ONCE: std::sync::atomic::AtomicBool =
72    std::sync::atomic::AtomicBool::new(false);
73
74/// Per-top-level-window record of the last child HWND to receive
75/// *intentional* keyboard focus — written by the pane subclass when an
76/// allowed-through `WM_SETFOCUS` lands and by `MainFocusReclaimTask`
77/// after its `SetFocus` on the main render widget. Programmatic pane
78/// focus that the redirect intercepts is NOT recorded — only paths the
79/// user actually intends.
80///
81/// Keyed by `GetAncestor(child, GA_ROOT)`. AgentMux runs multiple
82/// top-level windows in one process (primary `"main"` plus pool /
83/// sub-windows — see `state::list_browsers`); a single global slot
84/// would let `WM_ACTIVATE` on window A read window B's child and
85/// `SetFocus` the wrong one. See spec §4.5 for the empirical evidence
86/// (`docs/specs/SPEC_WINDOW_REACTIVATE_FOCUS_RESTORE_2026_05_23.md`).
87///
88/// `Mutex` (not `RwLock`): writes are rare (per intentional focus
89/// event), reads rarer still (per top-level `WM_ACTIVATE`). Contention
90/// is negligible.
91///
92/// Stale entries (child HWND destroyed) self-heal: the activate handler
93/// re-validates via `IsWindow` before calling `SetFocus`.
94pub static LAST_FOCUSED_BY_ROOT: std::sync::LazyLock<
95    std::sync::Mutex<std::collections::HashMap<usize, usize>>,
96> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
97
98/// Single write helper called from both intentional-focus sites (the
99/// pane subclass in this module and `MainFocusReclaimTask` in
100/// `ui_tasks.rs`). Resolves `child`'s top-level ancestor via
101/// `GetAncestor(GA_ROOT)` and stores the pair into `LAST_FOCUSED_BY_ROOT`.
102///
103/// Safety: `child` must be a live HWND that the caller intentionally
104/// just focused (so the recorded value is meaningful). Caller must
105/// also be on the Win32 UI thread, since `GetAncestor` and the static
106/// `LazyLock` aren't sensitive to thread but Win32 idiom is to keep
107/// HWND traffic on the message-pump thread.
108pub unsafe fn record_intentional_focus(child: *mut std::ffi::c_void) {
109    use windows_sys::Win32::UI::WindowsAndMessaging::{GetAncestor, GA_ROOT};
110    let root = GetAncestor(child, GA_ROOT);
111    if root.is_null() {
112        return;
113    }
114    if let Ok(mut map) = LAST_FOCUSED_BY_ROOT.lock() {
115        map.insert(root as usize, child as usize);
116        tracing::info!(
117            "[focus-track] LAST_FOCUSED_BY_ROOT[root={:p}] <= child={:p}",
118            root,
119            child,
120        );
121    }
122}
123
124/// Last-redirect timestamp per root HWND, used by
125/// `should_redirect_pane_focus_to_root` to rate-limit programmatic focus
126/// storms (setInterval-driven `window.focus()`, OAuth redirector pages,
127/// DOM mutation observers re-focusing on every change). Keyed by the root
128/// HWND cast to `usize`. Entries are overwritten on each pass and never
129/// explicitly removed — the map is bounded by the count of distinct
130/// top-level AgentMux windows seen in a session, which is small.
131static BROWSER_PANE_REDIRECT_LAST_AT: std::sync::LazyLock<
132    std::sync::Mutex<std::collections::HashMap<usize, std::time::Instant>>,
133> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
134
135/// Returns `true` iff the pane WM_SETFOCUS subclass should redirect to
136/// `root` via `SetFocus(root)`. Two guards, both motivated by the
137/// 2026-05-02 multi-window freeze investigation
138/// (`docs/specs/SPEC_WINDOW_FLEET_REDUCER_2026-05-02.md`):
139///
140/// 1. **Cross-window refusal.** If a *different* top-level HWND currently
141///    owns OS foreground (per `GetForegroundWindow()`), refuse to redirect.
142///    Same-thread `SetFocus` on a top-level HWND triggers `WM_ACTIVATE`,
143///    so redirecting here would steal foreground from the AgentMux window
144///    the user is interacting with. With two windows whose pane content
145///    both call `window.focus()` programmatically, the redirect itself
146///    drives a foreground ping-pong and the host UI thread saturates.
147///
148/// 2. **Per-root rate limit.** Even within the user's active window, cap
149///    redirects at once per 100 ms per root. Tight focus storms from page
150///    content can otherwise pile WM_SETFOCUS / WM_ACTIVATE chains onto
151///    the UI thread faster than they drain.
152///
153/// When this returns `false`, the pane WM_SETFOCUS handler still consumes
154/// the message (returns 0) — the pane simply doesn't get focus and the
155/// previous focus owner is undisturbed.
156unsafe fn should_redirect_pane_focus_to_root(root: *mut std::ffi::c_void) -> bool {
157    use windows_sys::Win32::UI::WindowsAndMessaging::GetForegroundWindow;
158    let current_fg = GetForegroundWindow();
159    if !current_fg.is_null() && current_fg != root {
160        return false;
161    }
162
163    let key = root as usize;
164    let now = std::time::Instant::now();
165    if let Ok(mut map) = BROWSER_PANE_REDIRECT_LAST_AT.lock() {
166        if let Some(last) = map.get(&key) {
167            if now.duration_since(*last) < std::time::Duration::from_millis(100) {
168                return false;
169            }
170        }
171        map.insert(key, now);
172    }
173    true
174}
175
176/// Subclass a browser pane's outer HWND (and every descendant HWND Chromium
177/// has already created) so `WM_SETFOCUS` is redirected back to the parent
178/// top-level window unless the focus change is user-initiated (see
179/// `ALLOW_BROWSER_PANE_FOCUS_ONCE`).
180///
181/// Without this, Chromium's internal SetFocus on the pane HWND (page load,
182/// JS `window.focus()`, etc.) steals the Windows-level keyboard focus —
183/// subsequent keystrokes go to the pane's renderer instead of the main
184/// window, so terminals, URL bars, and other inputs in the main UI stop
185/// responding.
186///
187/// Wired in by `browser_pane::callbacks::on_after_created_browser_pane` at create time and
188/// by `browser_pane::callbacks::on_load_end_browser_pane` after every navigation — Chromium
189/// recreates the `Chrome_RenderWidgetHostHWND` on every page load, so the
190/// subclass has to follow along or it ends up stranded on a destroyed HWND.
191pub unsafe fn install_browser_pane_focus_redirect(
192    hwnd: *mut std::ffi::c_void,
193    state: Arc<crate::state::AppState>,
194    block_id: String,
195) {
196    use std::sync::atomic::Ordering;
197    use windows_sys::Win32::UI::WindowsAndMessaging::{
198        CallWindowProcW, GetAncestor, SetWindowLongPtrW, GA_ROOT, GWLP_WNDPROC,
199        WM_SETFOCUS, WM_KILLFOCUS, WM_LBUTTONDOWN,
200    };
201    use windows_sys::Win32::UI::Input::KeyboardAndMouse::SetFocus;
202
203    // Register context so the WndProc's WM_LBUTTONDOWN handler can emit
204    // the click event without going through CEF callbacks (which only
205    // fire on Windows-level focus CHANGE — clicks inside an already-
206    // focused pane wouldn't produce a CEF focus callback at all).
207    if let Ok(mut map) = BROWSER_PANE_HWND_CONTEXT.lock() {
208        map.insert(hwnd as usize, BrowserPaneContext {
209            state: Arc::downgrade(&state),
210            block_id: block_id.clone(),
211        });
212    }
213
214    /// Walk from `hwnd` up the parent chain looking for a registered pane
215    /// context. Child HWNDs (Chrome_WidgetWin_1, Chrome_RenderWidgetHostHWND)
216    /// aren't themselves in the map — the outer pane HWND is. Safety bound
217    /// of 8 jumps is plenty; Chromium's pane hierarchy is only 2-3 deep.
218    unsafe fn find_context(mut hwnd: *mut std::ffi::c_void) -> Option<BrowserPaneContext> {
219        use windows_sys::Win32::UI::WindowsAndMessaging::GetParent;
220        for _ in 0..8 {
221            if let Ok(map) = BROWSER_PANE_HWND_CONTEXT.lock() {
222                if let Some(ctx) = map.get(&(hwnd as usize)) {
223                    return Some(ctx.clone());
224                }
225            }
226            let parent = GetParent(hwnd);
227            if parent.is_null() || parent == hwnd {
228                return None;
229            }
230            hwnd = parent;
231        }
232        None
233    }
234
235    unsafe extern "system" fn wndproc_hook(
236        hwnd: *mut std::ffi::c_void,
237        msg: u32,
238        wparam: usize,
239        lparam: isize,
240    ) -> isize {
241        // Diagnostic: surface mouse-wheel and key events so we can tell
242        // whether they reach the pane HWND at all when the user reports
243        // scrolling/typing breakage.
244        const WM_MOUSEWHEEL: u32 = 0x020A;
245        const WM_MOUSEHWHEEL: u32 = 0x020E;
246        const WM_KEYDOWN: u32 = 0x0100;
247        const WM_CHAR: u32 = 0x0102;
248        match msg {
249            WM_MOUSEWHEEL | WM_MOUSEHWHEEL => {
250                tracing::info!("[pane-wndproc] mouse-wheel hwnd={:p} msg=0x{:x}", hwnd, msg);
251            }
252            WM_KEYDOWN | WM_CHAR => {
253                tracing::info!("[pane-wndproc] key msg=0x{:x} wparam={}", msg, wparam);
254            }
255            WM_KILLFOCUS => {
256                tracing::info!("[pane-wndproc] WM_KILLFOCUS hwnd={:p}", hwnd);
257            }
258            _ => {}
259        }
260
261        // A click inside the pane HWND is the explicit "user wants to
262        // interact with the embedded page" signal. Chromium's own handler
263        // will call SetFocus(pane) next — arm ALLOW_BROWSER_PANE_FOCUS_ONCE so
264        // the WM_SETFOCUS branch below doesn't redirect it. Without this,
265        // clicks on the pane never transfer keyboard focus to Chromium
266        // (cursor works, typing goes nowhere — reported by user after
267        // the onMouseEnter→onMouseDown switch broke hover-focus-grab but
268        // the DOM-level mousedown never fires because the pane HWND
269        // intercepts the click at Win32 level, not DOM level).
270        if msg == WM_LBUTTONDOWN {
271            ALLOW_BROWSER_PANE_FOCUS_ONCE.store(true, Ordering::Relaxed);
272            // Emit the click event directly from the WndProc. We can't
273            // rely on CEF's FocusHandler::on_set_focus to emit, because
274            // CEF only fires that callback when Windows-level focus
275            // *changes* — clicks inside a pane that already has keyboard
276            // focus (the user clicked another DOM pane, then clicked
277            // back into this pane content) produce WM_LBUTTONDOWN but
278            // no CEF focus callback, leaving a flag armed forever.
279            if let Some(ctx) = find_context(hwnd) {
280                if let Some(state) = ctx.state.upgrade() {
281                    let block_id_short: String = ctx.block_id.chars().take(7).collect();
282                    tracing::info!(
283                        "[browser-pane:diag][{}] emit-clicked",
284                        block_id_short,
285                    );
286                    crate::events::emit_event_from_state(
287                        &state,
288                        "browser-pane-clicked",
289                        &serde_json::json!({ "block_id": ctx.block_id }),
290                    );
291                } else {
292                    tracing::warn!("[pane-wndproc] WM_LBUTTONDOWN — state dropped, skipping emit");
293                }
294            } else {
295                tracing::warn!("[pane-wndproc] WM_LBUTTONDOWN — no pane context for hwnd {:p}", hwnd);
296            }
297        }
298
299        if msg == WM_SETFOCUS {
300            // Intentional focus from the frontend's giveFocus() IPC: honor it
301            // once, then revert to redirect-mode for subsequent events.
302            if ALLOW_BROWSER_PANE_FOCUS_ONCE.swap(false, Ordering::Relaxed) {
303                tracing::info!("[pane-wndproc] WM_SETFOCUS allowed (intentional)");
304                record_intentional_focus(hwnd);
305                // Fall through to the original WndProc.
306            } else {
307                // Programmatic focus (page load, JS window.focus()): redirect
308                // to the TOP-LEVEL ancestor, not the immediate parent.
309                // `GetParent` on a descendant HWND (Chrome_WidgetWin_1,
310                // Chrome_RenderWidgetHostHWND, …) returns the pane's outer
311                // HWND, which is still inside the pane tree — redirecting
312                // there leaves focus stuck in the pane. `GetAncestor(GA_ROOT)`
313                // walks all the way up to the top-level window that hosts
314                // both main and pane, which is the correct place to land.
315                //
316                // Guard added 2026-05-02: refuse the redirect when another
317                // top-level HWND owns foreground or when this root has been
318                // redirected within the last 100 ms. See
319                // `should_redirect_pane_focus_to_root` for rationale.
320                let root = GetAncestor(hwnd, GA_ROOT);
321                if !root.is_null()
322                    && root != hwnd
323                    && should_redirect_pane_focus_to_root(root)
324                {
325                    SetFocus(root);
326                }
327                return 0;
328            }
329        }
330
331        let original = BROWSER_PANE_WNDPROCS
332            .lock()
333            .ok()
334            .and_then(|m| m.get(&(hwnd as usize)).copied())
335            .unwrap_or(0);
336        if original != 0 {
337            let proc_fn: unsafe extern "system" fn(
338                *mut std::ffi::c_void, u32, usize, isize,
339            ) -> isize = std::mem::transmute(original);
340            CallWindowProcW(Some(proc_fn), hwnd, msg, wparam, lparam)
341        } else {
342            0
343        }
344    }
345
346    // Subclass the outer HWND — but only once. Re-calling SetWindowLongPtrW
347    // would replace our hook with itself and poison BROWSER_PANE_WNDPROCS.
348    let already_hooked = BROWSER_PANE_WNDPROCS
349        .lock()
350        .ok()
351        .map(|m| m.contains_key(&(hwnd as usize)))
352        .unwrap_or(false);
353    if !already_hooked {
354        let original = SetWindowLongPtrW(hwnd, GWLP_WNDPROC, wndproc_hook as *const () as isize);
355        if original != 0 {
356            if let Ok(mut map) = BROWSER_PANE_WNDPROCS.lock() {
357                map.insert(hwnd as usize, original);
358            }
359            tracing::info!("[pane-subclass] installed focus-redirect WndProc on pane HWND {:p}", hwnd);
360        }
361    }
362
363    // Chromium creates inner HWNDs (widget + render) below the outer HWND.
364    // Mouse input reaches the deepest descendant, so we must walk the whole
365    // tree and subclass every one.
366    unsafe extern "system" fn enum_children(
367        child: *mut std::ffi::c_void,
368        _lparam: isize,
369    ) -> i32 {
370        let already = BROWSER_PANE_WNDPROCS
371            .lock()
372            .ok()
373            .map(|m| m.contains_key(&(child as usize)))
374            .unwrap_or(false);
375        if already {
376            return 1;
377        }
378        let orig = SetWindowLongPtrW(child, GWLP_WNDPROC, wndproc_hook as *const () as isize);
379        if orig != 0 {
380            if let Ok(mut map) = BROWSER_PANE_WNDPROCS.lock() {
381                map.insert(child as usize, orig);
382            }
383            let mut class_buf = [0u16; 64];
384            let n = windows_sys::Win32::UI::WindowsAndMessaging::GetClassNameW(
385                child, class_buf.as_mut_ptr(), class_buf.len() as i32,
386            );
387            let class_name = String::from_utf16_lossy(&class_buf[..n as usize]);
388            tracing::info!("[pane-subclass] subclassed child HWND {:p} class={}", child, class_name);
389        }
390        1 // continue
391    }
392    windows_sys::Win32::UI::WindowsAndMessaging::EnumChildWindows(
393        hwnd, Some(enum_children), 0,
394    );
395}
396
397// ── Tests ───────────────────────────────────────────────────────────────
398//
399// The Win32 calls themselves can't be unit-tested without a real HWND and
400// window message loop. What we can test here is the focus-bypass flag's
401// behavior as a simple AtomicBool — it's the only testable invariant the
402// `wndproc_hook` relies on.
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use std::sync::atomic::Ordering;
408
409    #[test]
410    fn allow_pane_focus_once_starts_false() {
411        // Note: this static is global to the process, so other tests can
412        // have modified it. Read-only assertion before mutation.
413        let _ = ALLOW_BROWSER_PANE_FOCUS_ONCE.load(Ordering::Relaxed);
414    }
415
416    #[test]
417    fn allow_pane_focus_once_swap_returns_prev_and_clears() {
418        ALLOW_BROWSER_PANE_FOCUS_ONCE.store(true, Ordering::Relaxed);
419        let prev = ALLOW_BROWSER_PANE_FOCUS_ONCE.swap(false, Ordering::Relaxed);
420        assert!(prev, "swap should return the prior true value");
421        assert!(!ALLOW_BROWSER_PANE_FOCUS_ONCE.load(Ordering::Relaxed),
422            "after swap(false), flag must be cleared");
423    }
424
425    #[test]
426    fn allow_pane_focus_once_swap_when_false_returns_false() {
427        ALLOW_BROWSER_PANE_FOCUS_ONCE.store(false, Ordering::Relaxed);
428        let prev = ALLOW_BROWSER_PANE_FOCUS_ONCE.swap(false, Ordering::Relaxed);
429        assert!(!prev, "swap on cleared flag should return false");
430    }
431}