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}