agentmux_cef/
app.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// CefApp and BrowserProcessHandler implementations for AgentMux host.
5// Creates a browser window loading the frontend URL on context initialization.
6//
7// Phase 2: Stores AppState and injects IPC port into the page after load.
8
9use cef::*;
10use std::cell::RefCell;
11use std::sync::Arc;
12
13use crate::client::*;
14use crate::state::AppState;
15
16// ---------------------------------------------------------------------------
17// Window & BrowserView delegates (CEF Views framework)
18// ---------------------------------------------------------------------------
19
20// Linux/macOS only: when `Some((state, window_label))`, `on_window_created`
21// inserts the Window into `state.windows[window_label]` and
22// `on_window_destroyed` removes it. Browser-pane creation
23// (`browser_pane/creation_views.rs`) looks up the parent Window by label
24// to call `add_overlay_view` on. Without this, panes opened from a
25// non-main window were silently routed to the main window. Popup
26// delegates (DevTools etc.) pass `None` and don't register because they
27// shouldn't host user-facing panes.
28// (kept as a regular comment — wrap_window_delegate! doesn't accept
29// doc-comments on struct fields.)
30wrap_window_delegate! {
31    pub struct AgentMuxWindowDelegate {
32        browser_view: RefCell<Option<BrowserView>>,
33        initial_bounds: Option<(i32, i32, i32, i32)>,
34        frameless: bool,
35        runtime_style: RuntimeStyle,
36        window_registration: Option<(Arc<AppState>, String)>,
37    }
38
39    impl ViewDelegate {
40        fn preferred_size(&self, _view: Option<&mut View>) -> Size {
41            Size {
42                width: 1200,
43                height: 800,
44            }
45        }
46    }
47
48    impl PanelDelegate {}
49
50    impl WindowDelegate {
51        fn on_window_created(&self, window: Option<&mut Window>) {
52            let browser_view = self.browser_view.borrow();
53            let (Some(window), Some(browser_view)) = (window, browser_view.as_ref()) else {
54                return;
55            };
56            let mut view = View::from(browser_view);
57            window.add_child_view(Some(&mut view));
58
59            // Position: use explicit bounds if provided, else 70% centered.
60            if let Some((x, y, w, h)) = self.initial_bounds {
61                window.set_bounds(Some(&Rect { x, y, width: w, height: h }));
62            } else if let Some((x, y, w, h)) = get_monitor_centered_70pct(window) {
63                window.set_bounds(Some(&Rect { x, y, width: w, height: h }));
64            }
65
66            // Linux/macOS only — register this Window in state.windows
67            // keyed by label, so the browser-pane Views path can attach
68            // pane overlays to the right window. Popup delegates pass
69            // `None` and don't register (they shouldn't host panes).
70            #[cfg(not(target_os = "windows"))]
71            if let Some((state, label)) = self.window_registration.as_ref() {
72                state.windows.lock().insert(label.clone(), window.clone());
73                tracing::info!(
74                    window_label = %label,
75                    "[browser-pane] registered Window in state.windows for pane attachment"
76                );
77
78                // Startup pool fill — Windows uses the launcher saga path
79                // (saga_dispatch.rs::LiveActionRunner is cfg(windows) only).
80                //
81                // Non-Windows: DISABLED entirely. Two separate blockers, both
82                // documented in docs/specs/linux-pool-startup-fill-2026-05-08.md:
83                //   1. promote_pool_window in commands/window_pool.rs has a
84                //      `cfg(not(target_os = "windows"))` impl that always
85                //      returns None — tear-off can't consume a pool window
86                //      on macOS or Linux, so any pre-warmed windows are
87                //      strictly wasted RAM. Codex P2 on PR #788 caught this
88                //      for the macOS path that an earlier revision enabled.
89                //   2. (Linux/Wayland only) POOL_OFFSCREEN_X = -32000 is a
90                //      Win32/X11 hack that the Wayland compositor ignores —
91                //      pool windows would appear on-screen as blank windows.
92                //
93                // Either blocker alone makes startup pool fill the wrong
94                // call here. When the platform pool implementation lands
95                // (Phase 7), this is the right place to re-enable.
96            }
97
98            // Chrome-style windows (DevTools popups) are shown immediately.
99            // Alloy-style windows defer to on_load_end in client.rs to avoid
100            // the DWM white flash on startup.
101            if self.runtime_style == RuntimeStyle::CHROME {
102                window.show();
103            }
104        }
105
106        fn on_window_destroyed(&self, _window: Option<&mut Window>) {
107            let mut browser_view = self.browser_view.borrow_mut();
108            *browser_view = None;
109
110            // Linux/macOS — un-register this Window from state.windows.
111            // Stale entries would cause subsequent pane creates targeting
112            // a destroyed window to silently no-op or worse.
113            #[cfg(not(target_os = "windows"))]
114            if let Some((state, label)) = self.window_registration.as_ref() {
115                state.windows.lock().remove(label);
116                tracing::info!(
117                    window_label = %label,
118                    "[browser-pane] unregistered Window on destroy"
119                );
120            }
121        }
122
123        fn can_close(&self, _window: Option<&mut Window>) -> i32 {
124            let browser_view = self.browser_view.borrow();
125            let Some(browser_view) = browser_view.as_ref() else {
126                return 1;
127            };
128            if let Some(browser) = browser_view.browser() {
129                let browser_host = browser.host().expect("BrowserHost is None");
130                browser_host.try_close_browser()
131            } else {
132                1
133            }
134        }
135
136        fn initial_show_state(&self, _window: Option<&mut Window>) -> ShowState {
137            ShowState::NORMAL
138        }
139
140        fn is_frameless(&self, _window: Option<&mut Window>) -> i32 {
141            self.frameless as i32
142        }
143
144        fn can_resize(&self, _window: Option<&mut Window>) -> i32 {
145            1
146        }
147
148        fn can_maximize(&self, _window: Option<&mut Window>) -> i32 {
149            1
150        }
151
152        fn can_minimize(&self, _window: Option<&mut Window>) -> i32 {
153            1
154        }
155
156        fn window_runtime_style(&self) -> RuntimeStyle {
157            self.runtime_style
158        }
159
160        // Wayland app_id / X11 WM_CLASS are set via an FFI override below
161        // (see install_linux_window_properties_override) instead of via this
162        // trait method, because the cef 146.7.0 wrapper's
163        // `From<CefStringUtf16> for _cef_string_utf16_t` impl silently drops
164        // `Clear` variants — the kind `CefString::from("agentmux")` produces.
165        // The trait method would set the values, the writeback would zero
166        // them, and CEF would emit `xdg_toplevel.set_app_id("")`.
167    }
168}
169
170/// Override the `get_linux_window_properties` function pointer on a
171/// `WindowDelegate` to write the AgentMux app_id directly to the C struct,
172/// bypassing the buggy `CefString` → `cef_string_utf16_t` conversion in the
173/// cef 146.7.0 wrapper (`Clear` variant gets dropped during writeback).
174///
175/// Without this, CEF emits `xdg_toplevel.set_app_id("")` and GNOME / KWin /
176/// sway can't match the window to `agentmux.desktop`, so the AgentMux icon
177/// never appears in the taskbar/dock/launcher.
178///
179/// Must be called once on every `WindowDelegate` we create (top-level, popup,
180/// new sub-window) before passing it to `window_create_top_level`.
181#[cfg(target_os = "linux")]
182pub fn install_linux_window_properties_override(delegate: &cef::WindowDelegate) {
183    use cef::ImplWindowDelegate;
184    // Disambiguate: WindowDelegate implements get_raw on three traits
185    // (ImplViewDelegate / ImplPanelDelegate / ImplWindowDelegate). We need
186    // the WindowDelegate one to get the right struct type for casting.
187    let raw: *mut cef::sys::_cef_window_delegate_t =
188        <cef::WindowDelegate as ImplWindowDelegate>::get_raw(delegate);
189    unsafe {
190        (*raw).get_linux_window_properties = Some(write_linux_window_properties);
191    }
192}
193
194/// Custom extern "C" shim invoked by libcef to populate
195/// `_cef_linux_window_properties_t`. Writes "agentmux" to wayland_app_id
196/// and the X11 wm_class fields via cef-dll-sys utf8→utf16 setters,
197/// then returns 1 so libcef uses the values.
198#[cfg(target_os = "linux")]
199extern "C" fn write_linux_window_properties(
200    _self_: *mut cef::sys::_cef_window_delegate_t,
201    _window: *mut cef::sys::_cef_window_t,
202    properties: *mut cef::sys::_cef_linux_window_properties_t,
203) -> std::os::raw::c_int {
204    if properties.is_null() {
205        return 0;
206    }
207    const APP_ID: &[u8] = b"agentmux";
208    unsafe {
209        let props = &mut *properties;
210        // The C struct's strings start zeroed (libcef constructs a default
211        // CefLinuxWindowProperties). cef_string_utf8_to_utf16 allocates a
212        // new utf-16 buffer and assigns it to the dest cef_string_utf16_t;
213        // ownership transfers to libcef which calls dtor when done.
214        cef::sys::cef_string_utf8_to_utf16(
215            APP_ID.as_ptr().cast(), APP_ID.len(), &mut props.wayland_app_id,
216        );
217        cef::sys::cef_string_utf8_to_utf16(
218            APP_ID.as_ptr().cast(), APP_ID.len(), &mut props.wm_class_class,
219        );
220        cef::sys::cef_string_utf8_to_utf16(
221            APP_ID.as_ptr().cast(), APP_ID.len(), &mut props.wm_class_name,
222        );
223    }
224    1
225}
226
227/// Compute a centered 70% rect for the monitor the window is currently on.
228/// Returns (x, y, width, height) or None if the monitor can't be determined.
229fn get_monitor_centered_70pct(window: &Window) -> Option<(i32, i32, i32, i32)> {
230    let bounds = window.bounds();
231    let (work_x, work_y, work_w, work_h) = get_monitor_work_area(bounds.x, bounds.y)?;
232    let w = (work_w as f64 * 0.70) as i32;
233    let h = (work_h as f64 * 0.70) as i32;
234    let x = work_x + (work_w - w) / 2;
235    let y = work_y + (work_h - h) / 2;
236    Some((x, y, w, h))
237}
238
239/// Get the work area (excluding taskbar/dock) of the monitor containing (px, py).
240/// Returns (x, y, width, height) of the work area.
241#[cfg(target_os = "windows")]
242pub fn get_monitor_work_area(px: i32, py: i32) -> Option<(i32, i32, i32, i32)> {
243    use windows_sys::Win32::Graphics::Gdi::{
244        MonitorFromPoint, GetMonitorInfoW, MONITORINFO, MONITOR_DEFAULTTOPRIMARY,
245    };
246    use windows_sys::Win32::UI::HiDpi::{GetDpiForMonitor, MDT_EFFECTIVE_DPI};
247    unsafe {
248        let point = windows_sys::Win32::Foundation::POINT { x: px, y: py };
249        let hmonitor = MonitorFromPoint(point, MONITOR_DEFAULTTOPRIMARY);
250        if hmonitor.is_null() {
251            return None;
252        }
253        let mut info: MONITORINFO = std::mem::zeroed();
254        info.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
255        if GetMonitorInfoW(hmonitor, &mut info) == 0 {
256            return None;
257        }
258        // Convert physical pixels → DIP (logical) pixels.
259        // CEF Views set_bounds() expects DIP; GetMonitorInfoW returns physical pixels.
260        // On Windows 10 @ 100%: dpi_x == 96 → scale == 1.0 (no change).
261        // On Windows 11 @ 125%: dpi_x == 120 → divide physical coords by 1.25.
262        let mut dpi_x: u32 = 96;
263        let mut dpi_y: u32 = 96;
264        let _ = GetDpiForMonitor(hmonitor, MDT_EFFECTIVE_DPI, &mut dpi_x, &mut dpi_y);
265        let scale = dpi_x as f64 / 96.0;
266        let rc = info.rcWork;
267        Some((
268            (rc.left as f64 / scale).round() as i32,
269            (rc.top as f64 / scale).round() as i32,
270            ((rc.right - rc.left) as f64 / scale).round() as i32,
271            ((rc.bottom - rc.top) as f64 / scale).round() as i32,
272        ))
273    }
274}
275
276#[cfg(target_os = "macos")]
277pub fn get_monitor_work_area(_px: i32, _py: i32) -> Option<(i32, i32, i32, i32)> {
278    // TODO: Use NSScreen.main.visibleFrame for proper work area (minus Dock/menu bar).
279    // CGMainDisplayID only returns the primary display — doesn't support multi-monitor
280    // and hardcoding menu bar height is fragile. Fall back to 1200x800 default.
281    None
282}
283
284#[cfg(target_os = "linux")]
285pub fn get_monitor_work_area(_px: i32, _py: i32) -> Option<(i32, i32, i32, i32)> {
286    // X11: XDisplayWidth/XDisplayHeight on the default screen.
287    // This is the full screen, not work area (no taskbar subtraction).
288    // TODO: use _NET_WORKAREA from the root window for proper work area.
289    None // Falls back to 1200x800 default
290}
291
292wrap_browser_view_delegate! {
293    pub struct AgentMuxBrowserViewDelegate {
294        runtime_style: RuntimeStyle,
295    }
296
297    impl ViewDelegate {}
298
299    impl BrowserViewDelegate {
300        fn on_popup_browser_view_created(
301            &self,
302            _browser_view: Option<&mut BrowserView>,
303            popup_browser_view: Option<&mut BrowserView>,
304            is_devtools: i32,
305        ) -> i32 {
306            // Create a new top-level window for the popup.
307            // DevTools windows (is_devtools != 0) get a native title bar so the
308            // user can see it's DevTools, move it, and close it with the X button.
309            // Regular popups stay frameless (matching the main window style).
310            let frameless = is_devtools == 0;
311            // DevTools popups are always Chrome-style (even from Alloy parents).
312            // The window runtime style must match the browser view style or CEF crashes.
313            let runtime_style = if is_devtools != 0 {
314                RuntimeStyle::CHROME
315            } else {
316                RuntimeStyle::ALLOY
317            };
318            let mut window_delegate = AgentMuxWindowDelegate::new(
319                RefCell::new(popup_browser_view.cloned()),
320                None,
321                frameless,
322                runtime_style,
323                None, // popup (DevTools etc.) — don't register; not pane-host
324            );
325            #[cfg(target_os = "linux")]
326            install_linux_window_properties_override(&window_delegate);
327            window_create_top_level(Some(&mut window_delegate));
328            1
329        }
330
331        fn browser_runtime_style(&self) -> RuntimeStyle {
332            self.runtime_style
333        }
334    }
335}
336
337// ---------------------------------------------------------------------------
338// CefApp + BrowserProcessHandler
339// ---------------------------------------------------------------------------
340
341wrap_app! {
342    pub struct AgentMuxApp {
343        state: Arc<AppState>,
344        ipc_port: u16,
345    }
346
347    impl App {
348        fn on_before_command_line_processing(
349            &self,
350            _process_type: Option<&CefString>,
351            command_line: Option<&mut CommandLine>,
352        ) {
353            if let Some(cmd) = command_line {
354                // Prevent empty browser on visibility change (CEF #3638).
355                let key = CefString::from("disable-features");
356                let val = CefString::from("CalculateNativeWinOcclusion");
357                cmd.append_switch_with_value(Some(&key), Some(&val));
358
359                // Initial background color, ARGB hex. alpha=00 → fully
360                // transparent → first-frame paint is alpha-aware so the
361                // CSS body background's rgba() composes with the desktop
362                // wallpaper. ff222222 here would clobber the alpha=0 we set
363                // via CefSettings.background_color in main.rs and force the
364                // first frame opaque (visible as a brief flash even after
365                // the renderer flips to ARGB on the first commit).
366                // Pair with: main.rs CefSettings.background_color = 0,
367                // app.rs BrowserSettings.background_color = 0, and the
368                // is_frameless main window delegate.
369                let bg_key = CefString::from("background-color");
370                let bg_val = CefString::from("00000000");
371                cmd.append_switch_with_value(Some(&bg_key), Some(&bg_val));
372
373                // Disable LCD text rendering — LCD subpixel anti-aliasing
374                // requires opaque backgrounds, so Chromium force-sets
375                // contents_opaque=true on every compositor layer that contains
376                // LCD-rendered text. With opaque layers, even CSS alpha<1
377                // backgrounds get rasterized as fully opaque, defeating the
378                // whole transparency cascade. Grayscale text AA on a
379                // translucent UI is the standard tradeoff for window
380                // transparency.
381                let lcd_key = CefString::from("disable-lcd-text");
382                cmd.append_switch(Some(&lcd_key));
383
384                // Allow the DevTools inspector page (served from the remote
385                // debugging server) to open its own WebSocket connection back
386                // to that same server.  Without this flag Chromium 107+ blocks
387                // cross-origin WebSocket upgrades to the debug port.
388                let ro_key = CefString::from("remote-allow-origins");
389                let ro_val = CefString::from("*");
390                cmd.append_switch_with_value(Some(&ro_key), Some(&ro_val));
391
392                // Skip Chrome features that add startup latency with no
393                // user-visible benefit in this app.
394                //
395                // `--no-proxy-server` was previously included here to skip
396                // WPAD/PAC auto-detect (2–3 s cold-start hit). Removed
397                // because it disables proxy support GLOBALLY — the
398                // `browser` widget loads arbitrary external URLs and
399                // would break for users on corporate networks where
400                // outbound HTTP requires the configured proxy. A future
401                // optimization could disable WPAD only without
402                // disabling explicit proxy config.
403                cmd.append_switch(Some(&CefString::from("disable-sync")));
404                cmd.append_switch(Some(&CefString::from("disable-extensions")));
405
406                // GPU compositing runs in a separate process (Chromium default).
407                // This allows Chromium to restart the GPU process transparently
408                // after driver resets (TDR, DXGI device removal, display power
409                // state changes). The ~100GB VA overhead is virtual, not physical
410                // (~20-50MB RSS), and negligible on 64-bit systems.
411                //
412                // Previously used --in-process-gpu to save VA space, but it left
413                // the app in a zombie white-screen state on GPU context loss with
414                // no recovery path. Removed in v0.33.66.
415
416                // NOTE: `--renderer-process-limit=1` was previously set here to
417                // protect against DevTools popups spawning extra renderers under
418                // an Alloy-mode assumption. The current Linux CEF build is NOT
419                // Alloy-mode for the user-visible UI: main window, every pool
420                // window, every tear-off window, and every browser-pane gets
421                // its own renderer process. Capping all of them to ONE shared
422                // renderer process serializes their JS event loops on a single
423                // thread, which manifests as hover/animation lag in the user-
424                // visible UI when pool windows are doing idle work. Removed
425                // 2026-05-09. See docs/specs/linux-cef-flags-audit-2026-05-08.md.
426            }
427        }
428
429        fn browser_process_handler(&self) -> Option<BrowserProcessHandler> {
430            Some(AgentMuxBrowserProcessHandler::new(
431                RefCell::new(None),
432                self.state.clone(),
433                self.ipc_port,
434            ))
435        }
436    }
437}
438
439// AgentMuxApp::new(state, ipc_port) is generated by the wrap_app! macro above.
440
441wrap_browser_process_handler! {
442    pub struct AgentMuxBrowserProcessHandler {
443        client: RefCell<Option<Client>>,
444        state: Arc<AppState>,
445        ipc_port: u16,
446    }
447
448    impl BrowserProcessHandler {
449        fn on_context_initialized(&self) {
450            debug_assert_ne!(currently_on(ThreadId::UI), 0);
451
452            // Create the client (browser-level callbacks) with state for IPC port injection.
453            {
454                let mut client = self.client.borrow_mut();
455                *client = Some(AgentMuxClient::new(
456                    AgentMuxHandler::new(self.state.clone(), self.ipc_port),
457                    false, // is_browser_pane = false — main browser takes focus normally
458                ));
459            }
460
461            // Browser settings.
462            let settings = BrowserSettings {
463                windowless_frame_rate: 60,
464                // ARGB: alpha=0 → SK_AlphaTRANSPARENT → enables Views-framework
465                // transparency in the patched libcef.so. Pair with the
466                // CefSettings::background_color flip in main.rs and the
467                // is_frameless=true main window delegate. See
468                // docs/research/cef-transparency-research-2026-05-10.md and
469                // docs/retros/cef-transparency-empirical-2026-05-11.md.
470                background_color: 0x00000000,
471                ..Default::default()
472            };
473
474            // Determine the URL to load.
475            let command_line = command_line_get_global().expect("Failed to get command line");
476            let url_switch = CefString::from("url");
477            let base_url = if command_line.has_switch(Some(&url_switch)) != 0 {
478                CefString::from(&command_line.switch_value(Some(&url_switch))).to_string()
479            } else {
480                String::new()
481            };
482            // If no URL specified, load from the IPC server (which serves static
483            // files from the bundled frontend). Fall back to Vite dev server ONLY
484            // in dev mode — in release builds, localhost:5173 doesn't exist and
485            // would show a raw browser error page.
486            let base_url = if base_url.is_empty() {
487                // Use the launcher's mode if it set the env, else
488                // fall back to detecting from the host exe path
489                // (covers standalone `task dev` runs).
490                let mode = agentmux_common::RuntimeMode::from_env().or_else(|| {
491                    std::env::current_exe()
492                        .ok()
493                        .and_then(|p| p.parent().map(|d| d.to_path_buf()))
494                        .map(|d| agentmux_common::RuntimeMode::current(&d))
495                });
496                let is_dev = matches!(mode, Some(agentmux_common::RuntimeMode::Dev { .. }));
497                let exe_dir = std::env::current_exe()
498                    .ok()
499                    .and_then(|p| p.parent().map(|d| d.to_path_buf()));
500                let has_frontend = exe_dir
501                    .as_ref()
502                    .map(|d| d.join("frontend/index.html").exists())
503                    .unwrap_or(false);
504                if has_frontend || !is_dev {
505                    // Production or portable: always use IPC server
506                    format!("http://127.0.0.1:{}", self.ipc_port)
507                } else {
508                    // Dev mode only: Vite HMR server
509                    "http://localhost:5173".to_string()
510                }
511            } else {
512                base_url
513            };
514
515            // Append IPC port and token as URL query parameters so the frontend
516            // can detect CEF mode and connect to the IPC server immediately,
517            // before on_load_end fires.
518            let separator = if base_url.contains('?') { "&" } else { "?" };
519            let url_with_ipc = format!(
520                "{}{}ipc_port={}&ipc_token={}",
521                base_url, separator, self.ipc_port, self.state.ipc_token
522            );
523            let url = CefString::from(url_with_ipc.as_str());
524
525            tracing::info!("Loading URL: {}{}ipc_port={}&ipc_token=<redacted>", base_url, separator, self.ipc_port);
526
527            // CEF Views mode — window NOT shown until on_load_end.
528            // No DwmExtendFrameIntoClientArea (causes white flash).
529            // CEF Views handles resize, snap, frameless natively.
530            {
531                let mut client = self.default_client();
532                let mut delegate = AgentMuxBrowserViewDelegate::new(RuntimeStyle::ALLOY);
533                let browser_view = browser_view_create(
534                    client.as_mut(),
535                    Some(&url),
536                    Some(&settings),
537                    None,
538                    None,
539                    Some(&mut delegate),
540                );
541
542                let mut window_delegate = AgentMuxWindowDelegate::new(
543                    RefCell::new(browser_view),
544                    None,
545                    true, // frameless — main window uses custom title bar
546                    RuntimeStyle::ALLOY,
547                    Some((self.state.clone(), "main".to_string())),
548                );
549                #[cfg(target_os = "linux")]
550                install_linux_window_properties_override(&window_delegate);
551                window_create_top_level(Some(&mut window_delegate));
552            }
553        }
554
555        fn default_client(&self) -> Option<Client> {
556            self.client.borrow().clone()
557        }
558    }
559}