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}