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}