agentmux_cef/
ui_tasks.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// CEF UI thread task dispatch.
5//
6// All CEF Views operations (Window::close, minimize, maximize, etc.) must run
7// on the CEF UI thread. IPC commands arrive on tokio threads. This module
8// provides tasks that can be posted to the UI thread via post_task().
9//
10// Key insight: don't pass Browser/Window handles across threads. Instead,
11// pass Arc<AppState> and look up the browser on the UI thread.
12//
13// Used on Linux (and macOS). On Windows, Win32 APIs are used directly since
14// they are safe to call from any thread.
15
16use std::sync::Arc;
17use cef::*;
18use crate::state::AppState;
19
20/// Get the CEF Views Window for a browser label on the UI thread.
21fn get_window_on_ui(state: &Arc<AppState>, label: &str) -> Option<Window> {
22    // Phase H.2.b — reducer-aware lookup with fallback.
23    let mut browser = state.get_browser(label)?;
24    let browser_view = browser_view_get_for_browser(Some(&mut browser))?;
25    browser_view.window()
26}
27
28// ── Deferred load_url (used by on_before_popup to avoid UI-thread deadlock)
29//
30// Calling `frame.load_url(url)` synchronously inside a CEF callback that
31// holds the handler's inner lock (e.g. `on_before_popup`) deadlocks on
32// link clicks: `load_url` kicks a new navigation which triggers
33// `on_loading_state_change` on the same thread, which also wants the
34// handler's lock. Posting the navigate as a separate UI task lets the
35// original callback return, release its lock, and the load starts
36// cleanly on the next message-loop turn. ─────────────────────────────────
37
38wrap_task! {
39    pub struct DeferredLoadUrlTask {
40        browser: Browser,
41        url: String,
42    }
43
44    impl Task {
45        fn execute(&self) {
46            let mut browser = self.browser.clone();
47            if let Some(frame) = browser.main_frame() {
48                frame.load_url(Some(&CefString::from(self.url.as_str())));
49            }
50        }
51    }
52}
53
54// ── Close ────────────────────────────────────────────────────────────────
55
56wrap_task! {
57    pub struct CloseWindowTask {
58        state: Arc<AppState>,
59        label: String,
60    }
61
62    impl Task {
63        fn execute(&self) {
64            if let Some(window) = get_window_on_ui(&self.state, &self.label) {
65                window.close();
66            }
67        }
68    }
69}
70
71pub fn post_close_window(state: &Arc<AppState>, label: &str) {
72    let mut task = CloseWindowTask::new(state.clone(), label.to_string());
73    post_task(ThreadId::UI, Some(&mut task));
74}
75
76// ── Minimize ─────────────────────────────────────────────────────────────
77
78wrap_task! {
79    pub struct MinimizeWindowTask {
80        state: Arc<AppState>,
81        label: String,
82    }
83
84    impl Task {
85        fn execute(&self) {
86            if let Some(window) = get_window_on_ui(&self.state, &self.label) {
87                window.minimize();
88            }
89        }
90    }
91}
92
93pub fn post_minimize_window(state: &Arc<AppState>, label: &str) {
94    let mut task = MinimizeWindowTask::new(state.clone(), label.to_string());
95    post_task(ThreadId::UI, Some(&mut task));
96}
97
98// ── Maximize (toggle) ────────────────────────────────────────────────────
99
100wrap_task! {
101    pub struct MaximizeWindowTask {
102        state: Arc<AppState>,
103        label: String,
104    }
105
106    impl Task {
107        fn execute(&self) {
108            if let Some(window) = get_window_on_ui(&self.state, &self.label) {
109                if window.is_maximized() != 0 {
110                    window.restore();
111                } else {
112                    window.maximize();
113                }
114            }
115        }
116    }
117}
118
119pub fn post_maximize_window(state: &Arc<AppState>, label: &str) {
120    let mut task = MaximizeWindowTask::new(state.clone(), label.to_string());
121    post_task(ThreadId::UI, Some(&mut task));
122}
123
124// ── Focus/Activate ───────────────────────────────────────────────────────
125
126wrap_task! {
127    pub struct FocusWindowTask {
128        state: Arc<AppState>,
129        label: String,
130    }
131
132    impl Task {
133        fn execute(&self) {
134            if let Some(window) = get_window_on_ui(&self.state, &self.label) {
135                window.activate();
136            }
137        }
138    }
139}
140
141pub fn post_focus_window(state: &Arc<AppState>, label: &str) {
142    let mut task = FocusWindowTask::new(state.clone(), label.to_string());
143    post_task(ThreadId::UI, Some(&mut task));
144}
145
146// ── Drag ─────────────────────────────────────────────────────────────────
147// CEF Views does not expose a programmatic drag-initiation API.
148// Begin a native window-move via the underlying CefWindow's BeginWindowDrag().
149// Posted on the CEF UI thread (BeginWindowDrag must be called there). On
150// Linux/Wayland this dispatches WmMoveResizeHandler::DispatchHostWindowDragMovement
151// → xdg_toplevel.move with the most recent input serial; on X11 it dispatches
152// an XEvent for _NET_WM_MOVERESIZE; on macOS it begins a system move loop.
153// The compositor handles the drag until the user releases the mouse button.
154// Note: views::Widget::RunMoveLoop() is the wrong API on Wayland (returns
155// immediately with a non-zero result) — see retro for details.
156//
157// Triggered by `start_window_drag` IPC from the renderer (useWindowDrag.linux.ts).
158// The renderer detects a left-button-down + threshold-crossing motion on a
159// HTCLIENT header element (NOT -webkit-app-region: drag — that suppresses
160// renderer events) and sends this IPC to initiate native drag.
161//
162// Requires CEF Patch: BeginWindowDrag added to CefWindow API (libcef/browser/views/window_impl.cc).
163#[cfg(not(target_os = "windows"))]
164wrap_task! {
165    pub struct StartWindowDragTask {
166        state: Arc<AppState>,
167        label: String,
168    }
169
170    impl Task {
171        fn execute(&self) {
172            // Call CefWindow::BeginWindowDrag via raw FFI. The cef Rust
173            // crate doesn't have a typed wrapper for our extension method
174            // (it's appended to _cef_window_t after get_runtime_style),
175            // so we get the raw *mut _cef_window_t and invoke the function
176            // pointer directly. cef-dll-sys's binding has been patched to
177            // include the begin_window_drag field at the end of the struct.
178            //
179            // Runtime ABI guard: every CEF struct begins with a size field
180            // (cef_base_ref_counted_t.size) populated by libcef itself. If
181            // libcef wasn't built from the AgentMux a5af/cef branch, the
182            // size will be the upstream value (≠ size_of::<_cef_window_t>()
183            // here, since our cef-dll-sys binding has begin_window_drag
184            // appended) and reading the extension slot would be UB. Bail
185            // out cleanly when sizes diverge.
186            use cef::ImplWindow;
187            if let Some(window) = get_window_on_ui(&self.state, &self.label) {
188                let raw_ptr = <cef::Window as ImplWindow>::get_raw(&window);
189                unsafe {
190                    let runtime_size = (*raw_ptr).base.base.base.size;
191                    let expected_size = std::mem::size_of::<cef::sys::_cef_window_t>();
192                    if runtime_size != expected_size {
193                        tracing::warn!(
194                            "[start_window_drag] libcef.so ABI mismatch — runtime _cef_window_t.size={} expected={} (libcef.so was not built from agentmux/7680-...; skipping native drag) label={}",
195                            runtime_size, expected_size, self.label
196                        );
197                        return;
198                    }
199                    if let Some(f) = (*raw_ptr).begin_window_drag {
200                        let result = f(raw_ptr);
201                        tracing::info!("[start_window_drag] BeginWindowDrag returned {} label={}", result, self.label);
202                    } else {
203                        tracing::warn!("[start_window_drag] BeginWindowDrag fn ptr is null label={}", self.label);
204                    }
205                }
206            } else {
207                tracing::warn!("[start_window_drag] no window for label={}", self.label);
208            }
209        }
210    }
211}
212
213#[cfg(not(target_os = "windows"))]
214pub fn post_start_drag(state: &Arc<AppState>, label: &str) {
215    let mut task = StartWindowDragTask::new(state.clone(), label.to_string());
216    post_task(ThreadId::UI, Some(&mut task));
217}
218
219#[cfg(target_os = "windows")]
220pub fn post_start_drag(_state: &Arc<AppState>, _label: &str) {}
221
222// ── Move window ───────────────────────────────────────────────────────────
223
224wrap_task! {
225    pub struct MoveWindowTask {
226        state: Arc<AppState>,
227        label: String,
228        dx: i32,
229        dy: i32,
230    }
231
232    impl Task {
233        fn execute(&self) {
234            if let Some(window) = get_window_on_ui(&self.state, &self.label) {
235                let bounds = window.bounds();
236                window.set_bounds(Some(&Rect {
237                    x: bounds.x + self.dx,
238                    y: bounds.y + self.dy,
239                    width: bounds.width,
240                    height: bounds.height,
241                }));
242            }
243        }
244    }
245}
246
247pub fn post_move_window(state: &Arc<AppState>, label: &str, dx: i32, dy: i32) {
248    let mut task = MoveWindowTask::new(state.clone(), label.to_string(), dx, dy);
249    post_task(ThreadId::UI, Some(&mut task));
250}
251
252// ── Set window to absolute position ──────────────────────────────────────
253
254wrap_task! {
255    pub struct SetWindowPositionTask {
256        state: Arc<AppState>,
257        label: String,
258        x: i32,
259        y: i32,
260    }
261
262    impl Task {
263        fn execute(&self) {
264            if let Some(window) = get_window_on_ui(&self.state, &self.label) {
265                let bounds = window.bounds();
266                window.set_bounds(Some(&Rect {
267                    x: self.x,
268                    y: self.y,
269                    width: bounds.width,
270                    height: bounds.height,
271                }));
272            }
273        }
274    }
275}
276
277pub fn post_set_window_position(state: &Arc<AppState>, label: &str, x: i32, y: i32) {
278    let mut task = SetWindowPositionTask::new(state.clone(), label.to_string(), x, y);
279    post_task(ThreadId::UI, Some(&mut task));
280}
281
282// ── Phase B.9.2 (WRR) — corrective absolute-position move ─────────────────
283//
284// Reducer-driven self-heal. Triggered by `Event::CorrectiveWindowMove` when
285// the reducer detects an off-monitor / sentinel-parked window that the user
286// has never foregrounded. We bypass `state.browsers` lookup-by-label (the
287// label might not be registered yet at correction time) and use Win32
288// SetWindowPos directly against the HWND. Must run on the UI thread because
289// CEF Views' window backing the HWND is owned by the UI thread.
290
291wrap_task! {
292    pub struct CorrectiveWindowMoveTask {
293        state: Arc<AppState>,
294        hwnd: u64,
295        x: i32,
296        y: i32,
297        w: i32,
298        h: i32,
299    }
300
301    impl Task {
302        fn execute(&self) {
303            #[cfg(target_os = "windows")]
304            unsafe {
305                use windows_sys::Win32::Foundation::HWND;
306                use windows_sys::Win32::UI::WindowsAndMessaging::{
307                    SetWindowPos, SWP_NOACTIVATE, SWP_NOZORDER,
308                };
309                let h = self.hwnd as HWND;
310                let ok = SetWindowPos(
311                    h,
312                    std::ptr::null_mut(),
313                    self.x,
314                    self.y,
315                    self.w,
316                    self.h,
317                    SWP_NOZORDER | SWP_NOACTIVATE,
318                );
319                tracing::info!(
320                    target: "wrr",
321                    "[wrr] corrective SetWindowPos hwnd={:#x} -> ({},{}) {}x{} ok={}",
322                    self.hwnd, self.x, self.y, self.w, self.h, ok != 0
323                );
324            }
325            #[cfg(not(target_os = "windows"))]
326            {
327                let _ = &self.state; // suppress unused on non-Windows
328                tracing::warn!(
329                    target: "wrr",
330                    "[wrr] corrective move requested on non-Windows host: ignored"
331                );
332            }
333        }
334    }
335}
336
337pub fn post_corrective_window_move(state: &Arc<AppState>, hwnd: u64, x: i32, y: i32, w: i32, h: i32) {
338    let mut task = CorrectiveWindowMoveTask::new(state.clone(), hwnd, x, y, w, h);
339    post_task(ThreadId::UI, Some(&mut task));
340}
341
342// Phase B.9.3 (WRR) — `Event::HostShouldQuit` handling lives in
343// `launcher_ipc::apply_event_to_shadow`. After three smoke
344// iterations (v0.33.491–v0.33.493) confirmed `cef::post_task`
345// silently drops new tasks during the last-window-closed
346// teardown window — even when previously-posted tasks still
347// run — we bypass CEF entirely and use Win32
348// `PostThreadMessage(host_main_tid, WM_QUIT, 0, 0)` via
349// `wrr::win_event::post_thread_quit_message`. The UI thread's
350// captured TID is stored at `install_hooks` time.
351
352// ── Create new window (CEF Views) ───────────────────────────────────────
353
354wrap_task! {
355    pub struct CreateWindowTask {
356        state: Arc<AppState>,
357        url: String,
358        label: String,
359        x: i32,
360        y: i32,
361        w: i32,
362        h: i32,
363        frameless: bool,
364    }
365
366    impl Task {
367        fn execute(&self) {
368            use std::cell::RefCell;
369
370            // Phase 1 diagnostic tracing — see
371            // docs/specs/SPEC_HOST_WINDOW_CREATION_RUNNER_2026-05-02.md.
372            // Identify which exact CEF call wedges the UI thread under
373            // concurrent window creation.
374            let t0 = std::time::Instant::now();
375            tracing::info!(label = %self.label, "[create-window] task entered UI thread");
376
377            let settings = BrowserSettings {
378                background_color: 0xFF000000, // ARGB: opaque black (matches pre-transparency-experiment baseline)
379                ..Default::default()
380            };
381            let cef_url = CefString::from(self.url.as_str());
382
383            // Get client from an existing TOP-LEVEL browser. Cannot use
384            // `first_browser()` here: that does `HashMap::iter().next()`
385            // over the full browsers map, which includes pane browsers.
386            // Pane browsers have `is_browser_pane=true` on their client.
387            // If we inherited that, the new window's `on_load_end` would
388            // take the pane early-return branch (client/mod.rs:954) and
389            // never call `window.show()` — backend reports success
390            // (status panel updates) but no OS window appears. Filter on
391            // label prefix: top-level labels are `window-…` (per
392            // client/mod.rs:218), panes are `browser-pane-…`.
393            let client = self
394                .state
395                .list_browsers()
396                .into_iter()
397                .find(|(label, _)| !label.starts_with("browser-pane-"))
398                .and_then(|(_, b)| b.host().map(|h| h.client()));
399            tracing::info!(
400                label = %self.label,
401                elapsed_us = t0.elapsed().as_micros() as u64,
402                client_found = client.is_some(),
403                "[create-window] got client"
404            );
405
406            let mut request_context = crate::commands::create_isolated_request_context(
407                &self.state, &self.label,
408            );
409            tracing::info!(
410                label = %self.label,
411                elapsed_us = t0.elapsed().as_micros() as u64,
412                "[create-window] request_context resolved"
413            );
414
415            let mut client_ref = client.flatten();
416            let mut bv_delegate = crate::app::AgentMuxBrowserViewDelegate::new(
417                RuntimeStyle::ALLOY,
418            );
419            let browser_view = browser_view_create(
420                client_ref.as_mut(),
421                Some(&cef_url),
422                Some(&settings),
423                None,
424                request_context.as_mut(),
425                Some(&mut bv_delegate),
426            );
427            tracing::info!(
428                label = %self.label,
429                elapsed_us = t0.elapsed().as_micros() as u64,
430                "[create-window] browser_view_create returned"
431            );
432
433            let mut wd = crate::app::AgentMuxWindowDelegate::new(
434                RefCell::new(browser_view),
435                Some((self.x, self.y, self.w, self.h)),
436                self.frameless,
437                RuntimeStyle::ALLOY,
438                Some((self.state.clone(), self.label.clone())),
439            );
440            #[cfg(target_os = "linux")]
441            crate::app::install_linux_window_properties_override(&wd);
442            window_create_top_level(Some(&mut wd));
443            tracing::info!(
444                label = %self.label,
445                elapsed_us = t0.elapsed().as_micros() as u64,
446                "[create-window] window_create_top_level returned"
447            );
448        }
449    }
450}
451
452pub fn post_create_window(
453    state: &Arc<AppState>,
454    url: &str,
455    label: &str,
456    x: i32, y: i32, w: i32, h: i32,
457    frameless: bool,
458) {
459    let mut task = CreateWindowTask::new(
460        state.clone(), url.to_string(), label.to_string(),
461        x, y, w, h, frameless,
462    );
463    post_task(ThreadId::UI, Some(&mut task));
464}
465
466// ── DevTools (toggle) ─────────────────────────────────────────────────────
467
468wrap_task! {
469    pub struct ShowDevToolsTask {
470        state: Arc<AppState>,
471        label: String,
472    }
473
474    impl Task {
475        fn execute(&self) {
476            // Phase H.2.b — reducer-aware lookup with fallback.
477            let browser = match self.state.get_browser(&self.label) {
478                Some(b) => b,
479                None => {
480                    tracing::warn!("[devtools] browser '{}' not found", self.label);
481                    return;
482                }
483            };
484
485            match browser.host() {
486                Some(host) => {
487                    // In CEF Views mode, window_info is ignored by show_dev_tools().
488                    // CEF routes the DevTools popup through on_popup_browser_view_created
489                    // in AgentMuxBrowserViewDelegate, which creates a native window for it.
490                    if host.has_dev_tools() != 0 {
491                        host.close_dev_tools();
492                    } else {
493                        host.show_dev_tools(None, None, None, None);
494                    }
495                }
496                None => {
497                    tracing::warn!("[devtools] no browser host for '{}'", self.label);
498                }
499            }
500        }
501    }
502}
503
504pub fn post_show_dev_tools(state: &Arc<AppState>, label: &str) {
505    let mut task = ShowDevToolsTask::new(state.clone(), label.to_string());
506    post_task(ThreadId::UI, Some(&mut task));
507}
508
509// ── Main-focus reclaim ────────────────────────────────────────────────────
510//
511// Reclaim keyboard focus for the main browser when the user clicks a
512// main-DOM input (address bar, etc). Runs on the CEF UI thread because:
513//   - host.set_focus / browser_view_get_for_browser require the UI thread
514//   - walking the HWND tree via EnumChildWindows is safer post-setup when
515//     Chromium has published all of its render widgets
516//
517// On Windows, after the Chromium-level focus flip we also walk the Views
518// window for the Chrome_RenderWidgetHostHWND and Win32-SetFocus it — without
519// that explicit Win32 SetFocus, keyboard events keep routing to whichever
520// pane HWND currently holds Win32 focus even though Chromium "thinks" main
521// is focused. Observed on v0.33.264: host.set_focus(1) on main left pane
522// keystrokes arriving at the pane HWND for >2 seconds.
523
524wrap_task! {
525    pub struct MainFocusReclaimTask {
526        state: Arc<AppState>,
527        label: String,
528    }
529
530    impl Task {
531        fn execute(&self) {
532            // Phase H.2.b — reducer-aware lookup with fallback.
533            let mut browser = match self.state.get_browser(&self.label) {
534                Some(b) => b,
535                None => {
536                    tracing::warn!("[main-focus-reclaim] no browser for label={}", self.label);
537                    return;
538                }
539            };
540
541            if let Some(host) = browser.host() {
542                host.set_focus(1);
543                tracing::info!("[main-focus-reclaim] host.set_focus(1) on label={}", self.label);
544            }
545
546            #[cfg(target_os = "windows")]
547            {
548                let views_top_hwnd = browser_view_get_for_browser(Some(&mut browser))
549                    .and_then(|bv| bv.window())
550                    .map(|w| w.window_handle().0 as *mut std::ffi::c_void)
551                    .filter(|p| !p.is_null());
552
553                // Collect every pane's outer HWND so we can skip render widgets
554                // that descend from them. Panes are siblings of main under the
555                // Views top-level, so a naive EnumChildWindows would pick up
556                // their Chrome_RenderWidgetHostHWND and SetFocus on the wrong
557                // target.
558                // Phase H.2.b — reducer-aware iteration with fallback.
559                let pane_outer_hwnds: Vec<*mut std::ffi::c_void> = self
560                    .state
561                    .list_browsers()
562                    .into_iter()
563                    .filter(|(k, _)| k.starts_with("browser-pane-"))
564                    .filter_map(|(_, mut b)| {
565                        b.host().and_then(|h| {
566                            let wh = h.window_handle();
567                            if wh.0.is_null() { None } else { Some(wh.0 as *mut std::ffi::c_void) }
568                        })
569                    })
570                    .collect();
571
572                match views_top_hwnd {
573                    Some(top_hwnd) => unsafe {
574                        let render = find_main_render_widget(top_hwnd, &pane_outer_hwnds);
575                        let target = render.unwrap_or(top_hwnd);
576                        windows_sys::Win32::UI::Input::KeyboardAndMouse::SetFocus(target as _);
577                        crate::browser_pane::hwnd::record_intentional_focus(target);
578                        tracing::info!(
579                            "[main-focus-reclaim] Win32 SetFocus target={:p} render_found={} panes_excluded={}",
580                            target,
581                            render.is_some(),
582                            pane_outer_hwnds.len(),
583                        );
584                    },
585                    None => {
586                        tracing::warn!(
587                            "[main-focus-reclaim] could not resolve Views top-level HWND for label={}",
588                            self.label,
589                        );
590                    }
591                }
592            }
593
594            // Defocus all live panes at the Chromium level too.
595            self.state.browser_panes.defocus_all(&self.state);
596        }
597    }
598}
599
600/// Walk descendants of `root` and return the first Chrome_RenderWidgetHostHWND
601/// whose ancestor chain does NOT pass through any of `pane_outer_hwnds`.
602/// Panes are siblings of main under the Views top-level, so without this
603/// filter the walk would happily pick a pane's render widget.
604#[cfg(target_os = "windows")]
605unsafe fn find_main_render_widget(
606    root: *mut std::ffi::c_void,
607    pane_outer_hwnds: &[*mut std::ffi::c_void],
608) -> Option<*mut std::ffi::c_void> {
609    use windows_sys::Win32::UI::WindowsAndMessaging::{
610        EnumChildWindows, GetClassNameW, GetParent,
611    };
612
613    struct Finder<'a> {
614        found: *mut std::ffi::c_void,
615        panes: &'a [*mut std::ffi::c_void],
616    }
617    let mut finder = Finder { found: std::ptr::null_mut(), panes: pane_outer_hwnds };
618
619    unsafe extern "system" fn cb(hwnd: *mut std::ffi::c_void, lparam: isize) -> i32 {
620        let finder = &mut *(lparam as *mut Finder);
621        let mut buf = [0u16; 64];
622        let n = GetClassNameW(hwnd, buf.as_mut_ptr(), buf.len() as i32);
623        if n > 0 {
624            let class = String::from_utf16_lossy(&buf[..n as usize]);
625            if class == "Chrome_RenderWidgetHostHWND" {
626                // Walk ancestors; if we pass through any pane outer HWND,
627                // this widget belongs to a pane, not main.
628                let mut descends_from_pane = false;
629                let mut cursor = GetParent(hwnd);
630                while !cursor.is_null() {
631                    if finder.panes.iter().any(|p| *p == cursor) {
632                        descends_from_pane = true;
633                        break;
634                    }
635                    cursor = GetParent(cursor);
636                }
637                if !descends_from_pane {
638                    finder.found = hwnd;
639                    return 0; // stop
640                }
641            }
642        }
643        1
644    }
645
646    EnumChildWindows(root, Some(cb), &mut finder as *mut _ as isize);
647    if finder.found.is_null() { None } else { Some(finder.found) }
648}