agentmux_cef\client/
wndproc.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Win32 frameless window setup + wndproc subclass. Extracted from
5//! client/mod.rs in task #182 PR-G.
6
7/// Set up a native frameless window: extend client area over the thick frame
8/// border so the resize handle is invisible, then subclass the window to
9/// handle WM_NCHITTEST for edge resize.
10///
11/// DwmExtendFrameIntoClientArea(-1) makes the entire frame transparent, but
12/// it also removes the non-client hit-test region. Without the subclass,
13/// Windows can't tell which part of the window edge should be a resize handle.
14/// The subclass returns HT{LEFT,RIGHT,TOP,BOTTOM,TOPLEFT,...} when the cursor
15/// is within RESIZE_BORDER pixels of the window edge.
16#[cfg(target_os = "windows")]
17pub(super) unsafe fn setup_native_frameless(hwnd: *mut std::ffi::c_void) {
18    use windows_sys::Win32::Graphics::Dwm::DwmExtendFrameIntoClientArea;
19    use windows_sys::Win32::UI::Controls::MARGINS;
20
21    let margins = MARGINS {
22        cxLeftWidth: -1,
23        cxRightWidth: -1,
24        cyTopHeight: -1,
25        cyBottomHeight: -1,
26    };
27    let result = DwmExtendFrameIntoClientArea(hwnd, &margins);
28    if result == 0 {
29        tracing::info!("Applied DwmExtendFrameIntoClientArea to hide resize border");
30    } else {
31        tracing::warn!("DwmExtendFrameIntoClientArea failed: hr={:#x}", result);
32    }
33}
34
35/// Map of HWND -> original WndProc for secondary windows with edge resize hooks.
36/// Stored here instead of GWLP_USERDATA to avoid clobbering CEF's data.
37#[cfg(target_os = "windows")]
38static ORIGINAL_WNDPROCS: std::sync::LazyLock<
39    std::sync::Mutex<std::collections::HashMap<usize, isize>>,
40> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
41
42/// Map of top-level HWND -> original WndProc for the focus-restore subclass
43/// installed by `install_top_level_focus_restore_hook`. Kept separate from
44/// `ORIGINAL_WNDPROCS` so the two hooks can coexist on the same HWND in
45/// either order (the focus-restore hook always passes through to its own
46/// recorded original, which transitively walks back through any other hook).
47#[cfg(target_os = "windows")]
48static FOCUS_RESTORE_WNDPROCS: std::sync::LazyLock<
49    std::sync::Mutex<std::collections::HashMap<usize, isize>>,
50> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
51
52// Pane Win32 focus-redirect subclass + ALLOW_BROWSER_PANE_FOCUS_ONCE flag moved to
53// `crate::browser_pane::hwnd` in Phase 2 of the modularization split. See
54// `docs/specs/SPEC_BROWSER_PANE_MODULARIZATION.md`.
55
56/// Install a WndProc hook on a SECONDARY window that handles:
57/// - WM_NCCALCSIZE: returns 0 to eliminate the non-client area (removes the
58///   wide title bar / top border that WS_THICKFRAME + DWM extension creates)
59/// - WM_NCHITTEST: returns HT{LEFT,RIGHT,...} for resize zones at window edges
60///
61/// MUST NOT be installed on the main CEF Views window — that window handles
62/// resize through its delegate, and hooking it clobbers CEF internals.
63#[cfg(target_os = "windows")]
64pub(super) unsafe fn install_frameless_resize_hook(hwnd: *mut std::ffi::c_void) {
65    use windows_sys::Win32::UI::WindowsAndMessaging::*;
66
67    const RESIZE_BORDER: i32 = 6;
68
69    unsafe extern "system" fn wndproc_hook(
70        hwnd: *mut std::ffi::c_void,
71        msg: u32,
72        wparam: usize,
73        lparam: isize,
74    ) -> isize {
75        match msg {
76            // Remove the non-client area entirely — this eliminates the wide
77            // top border that WS_THICKFRAME normally reserves for the title bar.
78            WM_NCCALCSIZE if wparam == 1 => {
79                // Returning 0 with wparam=1 tells Windows the client area
80                // fills the entire window rect. No title bar, no borders.
81                return 0;
82            }
83
84            // Suppress the DWM activation border — return TRUE without
85            // calling DefWindowProc so Windows doesn't repaint the frame.
86            WM_NCACTIVATE => {
87                return 1; // TRUE = allow activation, but skip default border paint
88            }
89
90            WM_NCHITTEST => {
91                let x = (lparam & 0xFFFF) as i16 as i32;
92                let y = ((lparam >> 16) & 0xFFFF) as i16 as i32;
93
94                let mut rect = std::mem::zeroed::<windows_sys::Win32::Foundation::RECT>();
95                GetWindowRect(hwnd, &mut rect);
96
97                let left = x - rect.left < RESIZE_BORDER;
98                let right = rect.right - x < RESIZE_BORDER;
99                let top = y - rect.top < RESIZE_BORDER;
100                let bottom = rect.bottom - y < RESIZE_BORDER;
101
102                if top && left { return HTTOPLEFT as isize; }
103                if top && right { return HTTOPRIGHT as isize; }
104                if bottom && left { return HTBOTTOMLEFT as isize; }
105                if bottom && right { return HTBOTTOMRIGHT as isize; }
106                if left { return HTLEFT as isize; }
107                if right { return HTRIGHT as isize; }
108                if top { return HTTOP as isize; }
109                if bottom { return HTBOTTOM as isize; }
110                // Not on an edge — fall through to original WndProc.
111            }
112
113            _ => {}
114        }
115
116        // Delegate to the original WndProc.
117        let key = hwnd as usize;
118        let original = ORIGINAL_WNDPROCS
119            .lock()
120            .ok()
121            .and_then(|map| map.get(&key).copied())
122            .unwrap_or(0);
123        if original != 0 {
124            CallWindowProcW(Some(std::mem::transmute(original)), hwnd, msg, wparam, lparam)
125        } else {
126            DefWindowProcW(hwnd, msg, wparam, lparam)
127        }
128    }
129
130    let original = GetWindowLongPtrW(hwnd, GWLP_WNDPROC);
131    if let Ok(mut map) = ORIGINAL_WNDPROCS.lock() {
132        map.insert(hwnd as usize, original);
133    }
134    SetWindowLongPtrW(hwnd, GWLP_WNDPROC, wndproc_hook as isize);
135    tracing::info!("Installed frameless resize hook (WM_NCCALCSIZE + WM_NCHITTEST)");
136}
137
138/// Subclass a top-level window's WndProc to handle `WM_ACTIVATE`: when the
139/// window is being activated (`wparam != WA_INACTIVE`), look up the last
140/// intentionally-focused child for *this* root in
141/// `LAST_FOCUSED_BY_ROOT` and `SetFocus` it. Closes the
142/// alt-tab-back-and-input-drops bug.
143///
144/// Spec: `docs/specs/SPEC_WINDOW_REACTIVATE_FOCUS_RESTORE_2026_05_23.md`
145/// §5.1.3.
146///
147/// SAFE on the main CEF Views window: this hook ONLY observes `WM_ACTIVATE`
148/// and ALWAYS passes the message through to the original WndProc. No
149/// message is short-circuited. That is the crucial difference from
150/// `install_frameless_resize_hook`, which returns early for
151/// `WM_NCCALCSIZE` / `WM_NCACTIVATE` and so MUST NOT be installed on main.
152///
153/// Idempotent: re-calling on an already-hooked HWND is a no-op.
154#[cfg(target_os = "windows")]
155pub(crate) unsafe fn install_top_level_focus_restore_hook(hwnd: *mut std::ffi::c_void) {
156    use std::sync::atomic::Ordering;
157    use windows_sys::Win32::UI::Input::KeyboardAndMouse::SetFocus;
158    use windows_sys::Win32::UI::WindowsAndMessaging::{
159        CallWindowProcW, DefWindowProcW, IsWindow, SetWindowLongPtrW, GWLP_WNDPROC, WM_ACTIVATE,
160    };
161
162    const WA_INACTIVE: u32 = 0;
163
164    unsafe extern "system" fn wndproc_hook(
165        hwnd: *mut std::ffi::c_void,
166        msg: u32,
167        wparam: usize,
168        lparam: isize,
169    ) -> isize {
170        if msg == WM_ACTIVATE {
171            // The low word of wParam is the activation state; the high word
172            // is the minimized-state flag, which we don't care about.
173            let activation_state = (wparam & 0xFFFF) as u32;
174            if activation_state != WA_INACTIVE {
175                // The activating window is `hwnd`, which IS its own
176                // top-level root — the map is keyed by root HWND.
177                let child = crate::browser_pane::hwnd::LAST_FOCUSED_BY_ROOT
178                    .lock()
179                    .ok()
180                    .and_then(|m| m.get(&(hwnd as usize)).copied())
181                    .unwrap_or(0);
182                if child != 0 {
183                    let child_hwnd = child as *mut std::ffi::c_void;
184                    if IsWindow(child_hwnd) != 0 {
185                        // Honor the next pane-WM_SETFOCUS instead of redirecting.
186                        crate::browser_pane::hwnd::ALLOW_BROWSER_PANE_FOCUS_ONCE
187                            .store(true, Ordering::Relaxed);
188                        SetFocus(child_hwnd);
189                        tracing::info!(
190                            "[focus-restore] WM_ACTIVATE root={:p} state={} -> SetFocus child={:p}",
191                            hwnd, activation_state, child_hwnd,
192                        );
193                    } else {
194                        tracing::info!(
195                            "[focus-restore] WM_ACTIVATE root={:p} stale child={:p} (IsWindow=0) — no-op",
196                            hwnd, child_hwnd,
197                        );
198                    }
199                } else {
200                    tracing::info!(
201                        "[focus-restore] WM_ACTIVATE root={:p} no recorded child — no-op",
202                        hwnd,
203                    );
204                }
205            }
206        }
207
208        // ALWAYS pass through. We observe WM_ACTIVATE; CEF still owns it.
209        let original = FOCUS_RESTORE_WNDPROCS
210            .lock()
211            .ok()
212            .and_then(|m| m.get(&(hwnd as usize)).copied())
213            .unwrap_or(0);
214        if original != 0 {
215            CallWindowProcW(Some(std::mem::transmute(original)), hwnd, msg, wparam, lparam)
216        } else {
217            DefWindowProcW(hwnd, msg, wparam, lparam)
218        }
219    }
220
221    let already_hooked = FOCUS_RESTORE_WNDPROCS
222        .lock()
223        .ok()
224        .map(|m| m.contains_key(&(hwnd as usize)))
225        .unwrap_or(false);
226    if already_hooked {
227        return;
228    }
229    let original = SetWindowLongPtrW(hwnd, GWLP_WNDPROC, wndproc_hook as *const () as isize);
230    if original != 0 {
231        if let Ok(mut map) = FOCUS_RESTORE_WNDPROCS.lock() {
232            map.insert(hwnd as usize, original);
233        }
234        tracing::info!(
235            "[focus-restore] installed WM_ACTIVATE observer on top-level HWND {:p}",
236            hwnd,
237        );
238    } else {
239        tracing::warn!(
240            "[focus-restore] SetWindowLongPtrW returned 0 for HWND {:p} — hook not installed",
241            hwnd,
242        );
243    }
244}
245
246/// Hide the given top-level HWND from the Windows taskbar via
247/// `ITaskbarList::DeleteTab`. The window remains fully usable — Alt-Tab still
248/// finds it, it takes focus, repaints, etc. — but the shell paints no taskbar
249/// button for it regardless of the user's "Combine taskbar buttons" setting.
250///
251/// Used only for `WindowKind::Subwindow` top-level windows. Must be called
252/// once the HWND exists (post-`on_after_created`) and re-applied on the
253/// `TaskbarCreated` broadcast after Explorer restarts.
254///
255/// Same primitive Electron uses in `NativeWindowViews::SetSkipTaskbar`
256/// (`shell/browser/native_window_views.cc`).
257#[cfg(target_os = "windows")]
258pub(super) unsafe fn skip_taskbar(hwnd: *mut std::ffi::c_void) {
259    use windows_sys::Win32::System::Com::{CoCreateInstance, CLSCTX_INPROC_SERVER};
260    use windows_sys::core::GUID;
261
262    // CLSID_TaskbarList
263    const CLSID_TASKBAR_LIST: GUID = GUID {
264        data1: 0x56FDF344,
265        data2: 0xFD6D,
266        data3: 0x11D0,
267        data4: [0x95, 0x8A, 0x00, 0x60, 0x97, 0xC9, 0xA0, 0x90],
268    };
269    // IID_ITaskbarList
270    const IID_TASKBAR_LIST: GUID = GUID {
271        data1: 0x56FDF342,
272        data2: 0xFD6D,
273        data3: 0x11D0,
274        data4: [0x95, 0x8A, 0x00, 0x60, 0x97, 0xC9, 0xA0, 0x90],
275    };
276
277    // Hand-rolled vtable — `windows-sys` doesn't expose `ITaskbarList` types
278    // at this feature level, and pulling in the full `windows` crate for one
279    // COM interface is overkill.
280    #[repr(C)]
281    struct ITaskbarList {
282        lp_vtbl: *const ITaskbarListVtbl,
283    }
284    #[repr(C)]
285    struct ITaskbarListVtbl {
286        query_interface: unsafe extern "system" fn(*mut ITaskbarList, *const GUID, *mut *mut core::ffi::c_void) -> i32,
287        add_ref: unsafe extern "system" fn(*mut ITaskbarList) -> u32,
288        release: unsafe extern "system" fn(*mut ITaskbarList) -> u32,
289        hr_init: unsafe extern "system" fn(*mut ITaskbarList) -> i32,
290        add_tab: unsafe extern "system" fn(*mut ITaskbarList, *mut core::ffi::c_void) -> i32,
291        delete_tab: unsafe extern "system" fn(*mut ITaskbarList, *mut core::ffi::c_void) -> i32,
292        activate_tab: unsafe extern "system" fn(*mut ITaskbarList, *mut core::ffi::c_void) -> i32,
293        set_active_alt: unsafe extern "system" fn(*mut ITaskbarList, *mut core::ffi::c_void) -> i32,
294    }
295
296    let mut tbl: *mut ITaskbarList = std::ptr::null_mut();
297    let hr = CoCreateInstance(
298        &CLSID_TASKBAR_LIST as *const GUID,
299        std::ptr::null_mut(),
300        CLSCTX_INPROC_SERVER,
301        &IID_TASKBAR_LIST as *const GUID,
302        &mut tbl as *mut _ as *mut _,
303    );
304    if hr < 0 || tbl.is_null() {
305        tracing::warn!("[skip_taskbar] CoCreateInstance(TaskbarList) failed: hr=0x{:x}", hr);
306        return;
307    }
308
309    let vtbl = &*(*tbl).lp_vtbl;
310    let hr = (vtbl.hr_init)(tbl);
311    if hr < 0 {
312        tracing::warn!("[skip_taskbar] HrInit failed: hr=0x{:x}", hr);
313        (vtbl.release)(tbl);
314        return;
315    }
316    let hr = (vtbl.delete_tab)(tbl, hwnd);
317    if hr < 0 {
318        tracing::warn!("[skip_taskbar] DeleteTab failed: hr=0x{:x}", hr);
319    } else {
320        tracing::info!("[skip_taskbar] hid HWND {:p} from taskbar", hwnd);
321    }
322    (vtbl.release)(tbl);
323}
324
325/// Load the app icon from the exe's embedded resource and set it on the window.
326/// This makes the icon appear in the taskbar and Alt+Tab switcher instead of
327/// the default CEF/Chromium icon.
328#[cfg(target_os = "windows")]
329pub(super) unsafe fn set_window_icon(hwnd: *mut std::ffi::c_void) {
330    use windows_sys::Win32::UI::WindowsAndMessaging::*;
331    use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
332
333    let hinstance = GetModuleHandleW(std::ptr::null());
334    if hinstance.is_null() {
335        tracing::warn!("set_window_icon: GetModuleHandleW returned null");
336        return;
337    }
338
339    // Load the big icon (32x32, for Alt+Tab / taskbar)
340    let icon_big = LoadImageW(
341        hinstance,
342        1 as *const u16, // Resource ID 1 (set by winres)
343        IMAGE_ICON,
344        32, 32,
345        LR_SHARED,
346    );
347    if !icon_big.is_null() {
348        SendMessageW(hwnd, WM_SETICON, ICON_BIG as usize, icon_big as isize);
349    }
350
351    // Load the small icon (16x16, for title bar)
352    let icon_small = LoadImageW(
353        hinstance,
354        1 as *const u16,
355        IMAGE_ICON,
356        16, 16,
357        LR_SHARED,
358    );
359    if !icon_small.is_null() {
360        SendMessageW(hwnd, WM_SETICON, ICON_SMALL as usize, icon_small as isize);
361    }
362
363    if !icon_big.is_null() || !icon_small.is_null() {
364        tracing::info!("Set window icon from embedded resource");
365    } else {
366        tracing::warn!("set_window_icon: no icon found in exe resource");
367    }
368}