agentmux_cef\client/mod.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// CefClient and associated handler implementations.
5// Manages browser lifecycle, display updates, and load errors.
6//
7// Phase 2: Stores browser ref in AppState and injects IPC port on page load.
8
9use cef::*;
10use std::sync::Arc;
11use parking_lot::Mutex;
12
13use crate::state::{AppState, WindowKind};
14
15// Phase B.9.3 — close-pool-browser task. Used by Stage 1 to defer
16// `close_browser` onto the CEF UI thread via `cef::post_task`, so
17// the call runs AFTER the current `on_before_close` unwinds.
18// `close_browser` called inline from inside another browser's
19// close callback re-enters CEF and hangs the UI thread (smoke
20// v0.33.497 confirmed).
21//
22// Two callers:
23// - Windows: HWND lookup fallback. Stage 1 prefers Win32
24// `PostMessage(hwnd, WM_CLOSE)` (bypasses CEF's task queue —
25// useful as a belt-and-suspenders mechanism), but if the
26// window handle is null (early/late lifecycle, e.g. browser
27// created without a Views top-level yet), fall through to this
28// task so the close still happens. Otherwise self.browser_list
29// never empties and Stage 2 never fires. (codex #601 P1.)
30// - Non-Windows: canonical path. macOS/Linux don't have
31// `PostMessage(WM_CLOSE)`; defer close_browser via post_task
32// for both correctness and portability. (reagent #601 P1.)
33wrap_task! {
34 pub struct ClosePoolBrowserTask {
35 browser: Browser,
36 }
37
38 impl Task {
39 fn execute(&self) {
40 let mut b = self.browser.clone();
41 if let Some(host) = b.host() {
42 host.close_browser(1); // force_close = true
43 }
44 }
45 }
46}
47
48/// Write a debug line to `%TEMP%\agentmux-close-debug.txt`.
49///
50/// Only active when `AGENTMUX_DEBUG_CLOSE=1` is set in the environment.
51/// In normal production runs the file is never written to.
52/// Always emits at tracing::debug level regardless of the env flag.
53pub fn dlog(msg: &str) {
54 use std::sync::OnceLock;
55 static ENABLED: OnceLock<bool> = OnceLock::new();
56 let enabled = *ENABLED.get_or_init(|| std::env::var("AGENTMUX_DEBUG_CLOSE").is_ok());
57
58 tracing::debug!("[close-debug] {}", msg);
59
60 if enabled {
61 use std::io::Write;
62 let path = std::env::temp_dir().join("agentmux-close-debug.txt");
63 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
64 use std::time::{SystemTime, UNIX_EPOCH};
65 let ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis();
66 let _ = writeln!(f, "[{}] {}", ms, msg);
67 }
68 tracing::info!("[close-debug] {}", msg);
69 }
70}
71
72/// Core handler state shared across all CEF callback interfaces.
73pub struct AgentMuxHandler {
74 browser_list: Vec<Browser>,
75 is_closing: bool,
76 state: Arc<AppState>,
77 ipc_port: u16,
78 is_browser_pane: bool,
79}
80
81mod handlers;
82mod helpers;
83#[cfg(target_os = "windows")]
84mod wndproc;
85
86pub use handlers::AgentMuxClient;
87
88use helpers::{js_string_literal, html_escape, backend_close_window};
89#[cfg(target_os = "windows")]
90use wndproc::{install_top_level_focus_restore_hook, set_window_icon, skip_taskbar};
91
92impl AgentMuxHandler {
93 pub fn new(state: Arc<AppState>, ipc_port: u16) -> Arc<Mutex<Self>> {
94 Self::new_with_browser_pane(state, ipc_port, false)
95 }
96
97 pub fn new_with_browser_pane(state: Arc<AppState>, ipc_port: u16, is_browser_pane: bool) -> Arc<Mutex<Self>> {
98 Arc::new(Mutex::new(Self {
99 browser_list: Vec::new(),
100 is_closing: false,
101 state,
102 ipc_port,
103 is_browser_pane,
104 }))
105 }
106
107 fn on_title_change(&mut self, browser: Option<&mut Browser>, title: Option<&CefString>) {
108 debug_assert_ne!(currently_on(ThreadId::UI), 0);
109
110 let title_str = title.map(|t| t.to_string()).unwrap_or_default();
111
112 // Update the window title via CEF Views.
113 let mut browser = browser.cloned();
114 if let Some(browser_view) = browser_view_get_for_browser(browser.as_mut()) {
115 if let Some(window) = browser_view.window() {
116 window.set_title(title);
117 }
118 }
119 // For Alloy-style native windows on Windows, update via Win32 API.
120 // Reagent P1 on #876: only call SetWindowTextW when CEF gave us an
121 // actual title. CEF fires `on_title_change` with `title = None` in
122 // several paths (e.g. about:blank, popup blockers) — passing "" to
123 // SetWindowTextW would blank the application window title in those
124 // cases. Preserve the existing title by skipping the Win32 update
125 // when title is None.
126 #[cfg(target_os = "windows")]
127 if title.is_some() {
128 if let Some(browser) = browser.as_ref() {
129 if let Some(host) = browser.host() {
130 let hwnd = host.window_handle();
131 if !hwnd.0.is_null() {
132 let title_wide: Vec<u16> = title_str
133 .encode_utf16()
134 .chain(std::iter::once(0))
135 .collect();
136 unsafe {
137 windows_sys::Win32::UI::WindowsAndMessaging::SetWindowTextW(
138 hwnd.0 as *mut std::ffi::c_void,
139 title_wide.as_ptr(),
140 );
141 }
142 }
143 }
144 }
145 }
146
147 // Emit live title to frontend for browser panes.
148 if self.is_browser_pane {
149 if let Some(b) = browser.as_ref() {
150 if let Some(block_id) =
151 crate::browser_pane::callbacks::resolve_pane_block_id(&self.state, b)
152 {
153 let block_id_short: String = block_id.chars().take(7).collect();
154 tracing::info!(
155 "[browser-pane:diag][{}] emit-title-change title={:?}",
156 block_id_short,
157 title_str,
158 );
159 crate::events::emit_event_from_state(
160 &self.state,
161 "browser-pane-title-change",
162 &serde_json::json!({ "block_id": block_id, "title": title_str }),
163 );
164 }
165 }
166 }
167 }
168
169 fn on_favicon_urlchange(
170 &mut self,
171 browser: Option<&mut Browser>,
172 icon_urls: Option<&mut CefStringList>,
173 ) {
174 if !self.is_browser_pane {
175 return;
176 }
177 let Some(b) = browser.as_deref() else { return };
178 let Some(block_id) =
179 crate::browser_pane::callbacks::resolve_pane_block_id(&self.state, b)
180 else {
181 return;
182 };
183
184 // Collect favicon URLs from the CefStringList. The list is an in-param
185 // provided by CEF — we read via the raw sys API so we don't need to
186 // consume (move) the borrowed reference.
187 //
188 // Reagent P1 on #876: `cef_string_list_value` writes into a
189 // `cef_string_t` whose `str_` field points at a freshly-allocated
190 // buffer owned by the list value (with `dtor` set to release it).
191 // Dropping `value` as a plain Rust struct would leak that buffer on
192 // every favicon URL CEF reports. After reading the string, we must
193 // invoke the dtor manually to free the buffer.
194 let urls: Vec<String> = if let Some(list) = icon_urls {
195 let raw: *mut cef::sys::_cef_string_list_t = list.into();
196 if let Some(raw_ref) = unsafe { raw.as_mut() } {
197 let count = unsafe { cef::sys::cef_string_list_size(raw_ref) };
198 (0..count)
199 .filter_map(|i| unsafe {
200 let mut value: cef::sys::cef_string_t = std::mem::zeroed();
201 if cef::sys::cef_string_list_value(raw_ref, i, &mut value) > 0 {
202 let s = CefString::from(std::ptr::from_ref(&value)).to_string();
203 // Free the buffer CEF allocated into `value.str_`.
204 if let Some(dtor) = value.dtor {
205 dtor(value.str_);
206 }
207 Some(s)
208 } else {
209 None
210 }
211 })
212 .collect()
213 } else {
214 vec![]
215 }
216 } else {
217 vec![]
218 };
219
220 let block_id_short: String = block_id.chars().take(7).collect();
221 tracing::info!(
222 "[browser-pane:diag][{}] emit-favicon-urls count={} first={:?}",
223 block_id_short,
224 urls.len(),
225 urls.first(),
226 );
227 crate::events::emit_event_from_state(
228 &self.state,
229 "browser-pane-favicon-urls",
230 &serde_json::json!({ "block_id": block_id, "urls": urls }),
231 );
232 }
233
234 fn on_after_created(&mut self, browser: Option<&mut Browser>) {
235 debug_assert_ne!(currently_on(ThreadId::UI), 0);
236
237 let browser = browser.cloned().expect("Browser is None");
238 tracing::info!("Browser created (total: {})", self.browser_list.len() + 1);
239
240 // Phase 1 diagnostic tracing — find the exact line that silences the
241 // UI thread under concurrent window creation. See
242 // docs/specs/SPEC_HOST_WINDOW_CREATION_RUNNER_2026-05-02.md.
243 let t0 = std::time::Instant::now();
244
245 // Phase B.5 (window_meta step d) — pop the pre-create
246 // handoff entry. Pre-step-d this was a label-only queue +
247 // separate `window_meta.insert` from the caller; now it's
248 // a single `PendingWindowCreation` carrying label + kind +
249 // parent_instance_id, eliminating the parallel-write race
250 // between caller and on_after_created.
251 //
252 // First-browser shortcut: "main" never has a pre-create
253 // handoff (host startup spawns it directly), so we
254 // synthesize a FullInstance entry. Subsequent windows pop
255 // their entry; if the queue is empty (legacy paths /
256 // unexpected races) fall back to a generated UUID label
257 // with FullInstance defaults.
258 // Phase H.2.b — reducer-aware emptiness check with fallback.
259 let pending = if self.state.browsers_is_empty() {
260 crate::state::PendingWindowCreation {
261 label: "main".to_string(),
262 kind: WindowKind::FullInstance,
263 parent_instance_id: None,
264 }
265 } else {
266 // Phase F.1 — dequeue via the host reducer. The reducer
267 // emits PendingWindowQueueEmpty on miss; the fallback
268 // (synthesize a UUID-labelled FullInstance entry) lives
269 // in the legacy code path it always has.
270 tracing::info!(
271 elapsed_us = t0.elapsed().as_micros() as u64,
272 "[on-after-created] dispatching DequeuePendingWindowCreation"
273 );
274 let out = self
275 .state
276 .host_dispatch(crate::reducer::HostCommand::DequeuePendingWindowCreation);
277 tracing::info!(
278 elapsed_us = t0.elapsed().as_micros() as u64,
279 dequeued_some = out.dequeued.is_some(),
280 "[on-after-created] DequeuePendingWindowCreation returned"
281 );
282 out.dequeued.unwrap_or_else(|| {
283 let lbl = format!("window-{}", uuid::Uuid::new_v4());
284 tracing::warn!(label = %lbl, "[on_after_created] no pending creation entry — defaulting to FullInstance");
285 crate::state::PendingWindowCreation {
286 label: lbl,
287 kind: WindowKind::FullInstance,
288 parent_instance_id: None,
289 }
290 })
291 };
292 let label = pending.label.clone();
293 let pending_kind = pending.kind;
294 let pending_parent = pending.parent_instance_id.clone();
295
296 // Phase H.2.d — legacy `state.browsers.insert` removed. Reducer's
297 // `RegisterBrowser` (dispatched below) is now the sole canonical
298 // mutation site. Smoke test on 0.33.585 verified parallel-write
299 // parity (zero drift across 18 RegisterBrowser/Unregister pairs).
300 let total = self.state.host_state.lock().browsers.len() + 1;
301 tracing::info!(
302 label = %label,
303 elapsed_us = t0.elapsed().as_micros() as u64,
304 total,
305 "[on-after-created] registering browser via reducer",
306 );
307 dlog(&format!("on_after_created: registered label={} total={}", label, total));
308
309 let is_top_level_window = !label.starts_with("browser-pane-");
310
311 // Determine BrowserKind from the LABEL prefix, not the
312 // AgentMuxClient `is_browser_pane` flag. Smoke test on 0.33.586 found
313 // top-level windows misclassified as `Pane { block_id: "" }`
314 // because `CreateWindowTask::execute` reuses an existing
315 // browser's CEF Client via `first_browser()` — if the iteration
316 // happens to pick a pane, the new window inherits `is_browser_pane=true`
317 // and the label-stripping in this branch produces an empty
318 // block_id (since the label starts with `window-` not
319 // `browser-pane-`). LABEL is the source of truth. See
320 // docs/retro/smoke-test-0.33.586-and-pr5-plan-2026-05-02.md.
321 //
322 // Classification:
323 // - label `browser-pane-<uuid>-<seq>` → Pane { block_id: uuid }
324 // - label `window-pool-*` + still in unpromoted_pool_labels →
325 // TopLevel { is_pool: true }
326 // - everything else (main, window-*, promoted pool windows) →
327 // TopLevel { is_pool: false }
328 let kind = if let Some(rest) = label.strip_prefix("browser-pane-") {
329 let block_id = rest
330 .rfind('-')
331 .map(|i| rest[..i].to_string())
332 .unwrap_or_default();
333 crate::state::BrowserKind::Pane { block_id }
334 } else if label.starts_with("window-pool-")
335 && self.state.is_unpromoted_pool_label(&label)
336 {
337 crate::state::BrowserKind::TopLevel { is_pool: true }
338 } else {
339 crate::state::BrowserKind::TopLevel { is_pool: false }
340 };
341 self.state.host_dispatch(
342 crate::reducer::HostCommand::RegisterBrowser {
343 label: label.clone(),
344 browser: browser.clone(),
345 kind,
346 },
347 );
348
349 // Phase B.5 (window_meta step d, refined) — write host's
350 // local `window_meta` ONCE here, synchronously from the
351 // popped pending entry. This is no longer the authoritative
352 // state (the launcher's `state.windows` is); it's a
353 // host-internal cache that covers two scenarios where the
354 // launcher-fed shadow can't:
355 //
356 // 1. `task dev` mode — no launcher IPC at all, shadow stays
357 // empty forever. open_subwindow's parent validation +
358 // cascade-close need a synchronous local source.
359 // 2. Cascade-close race — child opens just before parent
360 // closes; on_after_created→ReportWindowOpened→launcher
361 // →WindowOpened→shadow round-trip hasn't completed by
362 // the time parent's on_before_close runs. Without the
363 // local write, `subwindow_children_of` would miss the
364 // child and skip cascade close.
365 //
366 // The retired piece (step d's intent) is the
367 // **caller-side parallel write** — drag/window/window_pool
368 // no longer write meta themselves. Single canonical
369 // mutation site here. (codex P1 PR #592 round-2.)
370 if is_top_level_window {
371 let mut metas = self.state.window_meta.lock();
372 metas.insert(
373 label.clone(),
374 crate::state::WindowMeta {
375 label: label.clone(),
376 kind: pending_kind,
377 parent_instance_id: pending_parent.clone(),
378 },
379 );
380 }
381
382 // No DwmExtendFrameIntoClientArea — it causes the white flash.
383 // CEF Views handles frameless + resize via its delegate.
384
385 // Set the taskbar/title bar icon from the embedded exe resource, and
386 // for `Subwindow` top-levels, hide them from the taskbar via
387 // ITaskbarList::DeleteTab.
388 #[cfg(target_os = "windows")]
389 {
390 // Prefer CEF Views' `Window::window_handle()` — it targets the
391 // specific top-level window for THIS browser, avoiding the
392 // `find_own_top_level_window` fallback's "first visible HWND"
393 // ambiguity when multiple windows exist.
394 let mut browser_mut = browser.clone();
395 let views_top_hwnd = browser_view_get_for_browser(Some(&mut browser_mut))
396 .and_then(|bv| bv.window())
397 .map(|w| w.window_handle().0 as *mut std::ffi::c_void)
398 .filter(|p| !p.is_null());
399
400 let hwnd = views_top_hwnd.unwrap_or_else(|| {
401 browser.host()
402 .and_then(|h| {
403 let wh = h.window_handle();
404 if wh.0.is_null() { None } else { Some(wh.0 as *mut std::ffi::c_void) }
405 })
406 .unwrap_or_else(|| unsafe {
407 crate::commands::window::find_own_top_level_window()
408 })
409 });
410
411 if !hwnd.is_null() {
412 unsafe { set_window_icon(hwnd); }
413
414 // Subclass for the focus-restore-on-WM_ACTIVATE behavior
415 // (window-reactivate-focus-restore spec §5.1.3). Observes
416 // WM_ACTIVATE only; all messages pass through to CEF.
417 // Install on every top-level — both `main` and Subwindow.
418 if is_top_level_window {
419 unsafe { install_top_level_focus_restore_hook(hwnd); }
420 }
421
422 // Subwindow? Hide from taskbar. Full instances and browser-pane
423 // child HWNDs skip this branch.
424 if is_top_level_window {
425 // Phase B.5 (window_meta step d) — read kind from
426 // the pending entry we just popped. No
427 // window_meta lookup, no race window.
428 if pending_kind == WindowKind::Subwindow {
429 unsafe { skip_taskbar(hwnd); }
430 }
431 }
432 }
433 }
434
435 // Pane-specific on_after_created work (Z-order raise + Win32 focus
436 // subclass install) lives in `crate::browser_pane::callbacks` after Phase 4
437 // of the modularization split.
438 if self.is_browser_pane {
439 crate::browser_pane::callbacks::on_after_created_browser_pane(&self.state, &browser);
440 }
441
442 // Phase B.4 — report top-level windows to the launcher's
443 // read-only state mirror. Skips browser-pane child HWNDs and
444 // pool windows (they're not user-visible until promoted; the
445 // pool->user transition gets its own report in a follow-up).
446 // No-op if launcher IPC isn't connected (`task dev` mode).
447 if is_top_level_window && !label.starts_with("window-pool-") {
448 // Phase B.5 (window_meta step d) — kind/parent come
449 // from the pending entry we popped at the top of this
450 // fn, not a window_meta lookup.
451 let wire_kind = match pending_kind {
452 WindowKind::FullInstance => agentmux_common::ipc::WindowKind::FullInstance,
453 WindowKind::Subwindow => agentmux_common::ipc::WindowKind::Subwindow,
454 };
455 crate::launcher_ipc::report_window_opened(label.clone(), wire_kind, pending_parent.clone());
456
457 // Phase B.9.1 (WRR) — authoritative HWND link. We have
458 // both the label (popped from PendingWindowCreation
459 // above) and the native HWND (computed in the
460 // #[cfg(target_os = "windows")] block above as
461 // `views_top_hwnd` / `hwnd`). Sending an explicit
462 // ReportHwndOpened with `label_hint = Some(label)` here
463 // eliminates the race between the OS-driven
464 // EVENT_OBJECT_CREATE (which my hook captures with
465 // `label_hint = None` because pending_window_creations
466 // may already have been popped by the time the OS event
467 // bubbles back) and CEF's lifecycle. The OS-event path
468 // still runs as belt-and-suspenders for non-CEF windows
469 // / future detection of strays. (The prior pending_hwnds
470 // entry from the OS event is harmless — it ages out on
471 // the next event-driven reconciliation pass.)
472 #[cfg(target_os = "windows")]
473 {
474 // Recompute the HWND here — the prior #[cfg] block
475 // computed `hwnd` as a local that's not in scope at
476 // this site. The CEF Browser API is cheap to query
477 // a second time. Precedence: Views' window handle →
478 // host's window handle. NO fallback to
479 // `find_own_top_level_window()` — that function uses
480 // `EnumWindows` and returns the FIRST visible window
481 // belonging to this process, which in a multi-window
482 // session is some OTHER window's HWND. Sending that
483 // as authoritative `Some(label)` would corrupt the
484 // OTHER label's mirror via the `Repaired` arm in
485 // `apply_hwnd_opened`. (reagent P1 PR #664 round 3.)
486 //
487 // If both Views and host return null (transient
488 // lifecycle case), skip the explicit dispatch. The
489 // launcher's drain-on-WindowOpened fallback links
490 // the recent pending HWND from WM_CREATE — that's
491 // the sole link path when `hwnd_val=0`. The drain
492 // is reliable when WM_CREATE arrived recently (within
493 // the launcher's 2s age limit); the only failure mode
494 // is no WM_CREATE-pending entry within that window,
495 // in which case the mirror stays hwnd=None — same
496 // outcome as pre-PR-664 for that edge case, no worse.
497 let mut browser_for_wrr = browser.clone();
498 let views_hwnd = browser_view_get_for_browser(Some(&mut browser_for_wrr))
499 .and_then(|bv| bv.window())
500 .map(|w| w.window_handle().0 as *mut std::ffi::c_void)
501 .filter(|p| !p.is_null());
502 let host_hwnd = browser.host().and_then(|h| {
503 let wh = h.window_handle();
504 if wh.0.is_null() {
505 None
506 } else {
507 Some(wh.0 as *mut std::ffi::c_void)
508 }
509 });
510 let hwnd_val = views_hwnd.or(host_hwnd).map(|p| p as u64).unwrap_or(0);
511 if hwnd_val != 0 {
512 crate::launcher_ipc::report_hwnd_opened(
513 hwnd_val,
514 "Chrome_WidgetWin_1".to_string(),
515 label.clone(),
516 Some(label.clone()),
517 );
518 } else {
519 // Both sources null. Launcher's drain-on-WindowOpened
520 // fallback should still link the pending HWND from
521 // WM_CREATE; if that race lost too, the mirror
522 // stays hwnd=None for this window — degraded but
523 // not corrupted. Log at WARN so the regression is
524 // visible if it happens.
525 tracing::warn!(
526 target: "wrr",
527 label = %label,
528 "[wrr] on_after_created: hwnd_val=0 from both Views and host — \
529 relying on launcher's pending_hwnds drain fallback"
530 );
531 }
532 }
533 // Phase B.4 follow-up — drift check after the open.
534 crate::launcher_ipc::compute_and_report_host_counts(&self.state);
535 }
536
537 self.browser_list.push(browser);
538
539 // Tear-off Phase 6 — pre-warmed window pool.
540 // - When the "main" window registers, kick off the initial pool spawn.
541 // - When a "window-pool-*" window registers, log only — actual
542 // queue insertion waits for the frontend's renderer-ready IPC
543 // so emit_event_to_window doesn't race the listener install.
544 if label == "main" {
545 crate::commands::window_pool::init_pool(&self.state);
546 } else if label.starts_with("window-pool-") {
547 crate::commands::window_pool::register_pool_window(&self.state, &label);
548 }
549 }
550
551 fn do_close(&mut self, _browser: Option<&mut Browser>) -> bool {
552 debug_assert_ne!(currently_on(ThreadId::UI), 0);
553
554 if self.browser_list.len() == 1 {
555 self.is_closing = true;
556 }
557 // Return false to allow the close.
558 false
559 }
560
561 /// Intercept `target="_blank"` / `window.open()` from embedded pages so
562 /// they don't spawn rogue top-level CEF windows. Instead navigate the
563 /// **current** frame to the target URL — matches the UX expectation
564 /// that AgentMux owns window management, not the page.
565 ///
566 /// Returning non-zero cancels popup creation. Applies to both main
567 /// and pane clients: main's frontend never relies on `window.open`
568 /// (link clicks go through `openExternal` IPC), and panes explicitly
569 /// don't want popups. See
570 /// specs/SPEC_BROWSER_PANE_DEFAULT_URL_AND_POPUP_2026_04_21.md.
571 ///
572 /// **The `load_url` call is deferred via `post_task`**, not run inline.
573 /// Inline `load_url` caused a UI-thread deadlock on link click:
574 /// `on_before_popup` runs while `AgentMuxLifeSpanHandler` holds
575 /// `self.inner.lock()` (via the wrap macro). Inline `load_url` starts
576 /// a new navigation on the same UI thread, which triggers
577 /// `on_loading_state_change` on `AgentMuxLoadHandler`, which also
578 /// tries to take `self.inner.lock()` → deadlock. The host hung with
579 /// backend heartbeats still running but the whole UI frozen. Posting
580 /// the `load_url` as a separate UI task lets the popup handler
581 /// return, release the lock, then pick up the load on the next loop
582 /// iteration.
583 fn on_before_popup(
584 &mut self,
585 browser: Option<&mut Browser>,
586 _frame: Option<&mut Frame>,
587 target_url: Option<&CefString>,
588 _target_disposition: WindowOpenDisposition,
589 ) -> bool {
590 let url = target_url.map(|s| s.to_string()).unwrap_or_default();
591 if url.is_empty() {
592 // Nothing useful to navigate to; just cancel the popup.
593 return true;
594 }
595 if let Some(b) = browser {
596 let browser_clone = b.clone();
597 let mut task = crate::ui_tasks::DeferredLoadUrlTask::new(
598 browser_clone,
599 url.clone(),
600 );
601 cef::post_task(cef::ThreadId::UI, Some(&mut task));
602 }
603 tracing::info!(
604 is_browser_pane = %self.is_browser_pane,
605 url = %url,
606 "popup intercepted — deferred navigation of current frame",
607 );
608 true // cancel the top-level popup creation
609 }
610
611 fn on_before_close(&mut self, browser: Option<&mut Browser>) {
612 debug_assert_ne!(currently_on(ThreadId::UI), 0);
613
614 // Phase B.9.3 — diagnostic trace at debug level. Filtered
615 // out in production (default RUST_LOG=info). Enable via
616 // RUST_LOG="info,wrr-trace=debug" when investigating
617 // close-cascade issues.
618 tracing::debug!(
619 target: "wrr-trace",
620 "[trace] on_before_close ENTER; self.browser_list.len()={} is_browser_pane={}",
621 self.browser_list.len(), self.is_browser_pane
622 );
623 dlog(&format!("on_before_close fired; browser_list.len()={}", self.browser_list.len()));
624
625 let mut browser = browser.cloned().expect("Browser is None");
626
627 // Unregister browser from the reducer's `browsers` map and get its
628 // label. Phase H.2.d — legacy `state.browsers.lock().remove` removed;
629 // reducer is sole source of truth (see PR #4 commit 2 H.2.c flip).
630 // Find-by-identity loop now iterates reducer-backed snapshot via
631 // `state.list_browsers()`, then dispatches `UnregisterBrowser`.
632 let snapshot = self.state.list_browsers();
633 let keys: Vec<&String> = snapshot.iter().map(|(k, _)| k).collect();
634 dlog(&format!("browsers map keys: {:?}", keys));
635 let label = snapshot
636 .iter()
637 .find(|(_, b)| {
638 let mut b = b.clone();
639 b.is_same(Some(&mut browser)) != 0
640 })
641 .map(|(k, _)| k.clone());
642 dlog(&format!("label found: {:?}", label));
643 if let Some(ref lbl) = label {
644 self.state.host_dispatch(
645 crate::reducer::HostCommand::UnregisterBrowser { label: lbl.clone() },
646 );
647 let remaining = self.state.host_state.lock().browsers.len();
648 tracing::info!(
649 "Unregistered browser: label={} (remaining: {})",
650 lbl,
651 remaining
652 );
653 }
654
655 // Pane-specific on_before_close work (drain lifecycle entry) lives
656 // in `crate::browser_pane::callbacks` after Phase 4.
657 if let Some(ref lbl) = label {
658 if lbl.starts_with("browser-pane-") {
659 crate::browser_pane::callbacks::on_before_close_browser_pane(&self.state, lbl);
660 }
661 // Pool-window cleanup — release the respawn semaphore +
662 // drop the label from the queue if the window died before
663 // promote (renderer crash, OS-level close). Without this
664 // the pool would never refill.
665 if lbl.starts_with("window-pool-") {
666 crate::commands::window_pool::on_pool_window_destroyed(&self.state, lbl);
667 }
668 // Phase B.4 — mirror the close to the launcher. Skip
669 // browser-pane child HWNDs (never reported as open).
670 // For everything else, send unconditionally: the launcher
671 // reducer silently no-ops on unknown labels (codex P2
672 // PR #577 round-2 made `WindowClosed` strictly paired
673 // with `WindowOpened`), so pre-promote pool deaths and
674 // post-pop / pre-validation orphans are filtered there
675 // — no host-side guard needed. Pool inventory updates
676 // travel via `ReportPoolWindowRemoved` from
677 // `on_pool_window_destroyed` and `promote_pool_window`.
678 if !lbl.starts_with("browser-pane-") {
679 crate::launcher_ipc::report_window_closed(lbl.clone());
680 // Phase B.4 follow-up — drift check after the close.
681 crate::launcher_ipc::compute_and_report_host_counts(&self.state);
682 }
683 }
684
685 // Phase B.5 (window_id_map step d) — host no longer mutates
686 // `window_id_map`. The launcher's `state.backend_window_ids`
687 // (B.5 step a) is the sole authority; we look up the wid
688 // via the shadow-first helper before notifying the launcher
689 // to drop it. The wid lookup at close time is safe even
690 // without the host fallback because the frontend's original
691 // `register_backend_window` ran long before close — shadow
692 // has been populated for the entire window lifetime.
693 let backend_window_id = label.as_deref().and_then(|lbl| {
694 let wid = self.state.backend_window_id(lbl);
695 dlog(&format!("backend_window_id({:?}) => {:?}", lbl, wid));
696 crate::launcher_ipc::report_backend_window_id_unregistered(lbl.to_string());
697 wid
698 });
699
700 // Pull and remove the closing window's meta; if it's a FullInstance,
701 // cascade-close every Subwindow whose parent_instance_id points to it.
702 // See `docs/specs/SPEC_MULTIWINDOW_TASKBAR_GROUPING.md` §2.3.
703 //
704 // Phase B.5 (window_meta step d, refined) — read closing
705 // meta via shadow-first helper, drop the host-side cache
706 // entry (single canonical mutation site for window_meta
707 // post-refinement: insert in on_after_created, remove here).
708 let closing_meta = label
709 .as_deref()
710 .and_then(|lbl| self.state.window_meta(lbl));
711 if let Some(lbl) = label.as_deref() {
712 self.state.window_meta.lock().remove(lbl);
713 }
714 if let Some(meta) = &closing_meta {
715 if meta.kind == WindowKind::FullInstance {
716 let child_labels = self.state.subwindow_children_of(&meta.label);
717 for child_label in child_labels {
718 // Phase H.2.b — reducer-aware lookup with fallback.
719 if let Some(mut child) = self.state.get_browser(&child_label) {
720 if let Some(host) = child.host() {
721 tracing::info!(parent = %meta.label, child = %child_label, "[subwindow-cascade] closing sub-window");
722 host.close_browser(1);
723 }
724 }
725 }
726 }
727 }
728
729 // Phase F.6 — narrate the pane-reap step for the launcher's
730 // window-cleanup-cascade saga. By the time we reach here, the
731 // pane lifecycle drain (`on_before_close_browser_pane` for browser-
732 // pane labels) and the subwindow cascade above have run for
733 // this label. The saga uses this signal as the Step 1
734 // terminal so it can advance to Step 2 (drain-pool decision).
735 //
736 // Skip for browser-pane labels: the saga is triggered by
737 // `Event::WindowClosed`, which only fires for non-pane
738 // top-level windows; emitting `PanesReaped` for pane labels
739 // would be a stray report (no in-flight saga to consume it).
740 // Same gate as `report_window_closed` above — skip
741 // browser-pane-* labels (sub-views, not top-level windows).
742 // Don't filter window-pool-* here: filtering on prefix would
743 // wrongly suppress promoted pool windows (which keep the
744 // `window-pool-*` prefix but ARE tracked windows). Stray
745 // events for unpromoted-pool drains are emitted but harmless
746 // — no F.6 saga is in flight to consume them.
747 if let Some(ref lbl) = label {
748 if !lbl.starts_with("browser-pane-") {
749 crate::launcher_ipc::report_panes_reaped(lbl.clone());
750 }
751 }
752
753 dlog(&format!("backend_window_id: {:?}", backend_window_id));
754
755 if let Some(index) = self
756 .browser_list
757 .iter()
758 .position(|elem| elem.is_same(Some(&mut browser)) != 0)
759 {
760 self.browser_list.remove(index);
761 }
762
763 dlog(&format!("browser_list after remove: {}", self.browser_list.len()));
764
765 // App-exit decision: count remaining USER-FACING browsers.
766 // Unpromoted pool windows are pre-warmed scratch windows
767 // hidden from the user via WS_EX_TOOLWINDOW — they have no
768 // taskbar entry, can't be closed by the user, and would
769 // otherwise keep the app alive forever after the last
770 // visible window closes. Browser-pane child HWNDs
771 // (`browser-pane-*`) are sub-views of a parent window, not
772 // standalone instances, so they don't count either.
773 //
774 // Use `user_visibility_snapshot()` — atomic read of pool
775 // inventory (`unpromoted` ∪ `pool.queue`) AND the browser
776 // registry under ONE host_state lock. Both pool states are
777 // hidden off-screen with no user UI; counting them as
778 // user-visible inflates this gate and prevents the cascade
779 // from firing when the user really did close their last
780 // visible window.
781 //
782 // The single-lock read is required because a two-lock
783 // variant races against `promote_pool_window`: a label can
784 // move from `pool.queue` to promoted between the two reads,
785 // leaving the stale inventory excluding a now-real user
786 // window — making `was_last` true and triggering a cascade
787 // that closes the freshly torn-off window.
788 //
789 // Promoted pool windows ARE counted: they're removed from
790 // BOTH pool sets at promote time.
791 let (user_browser_count, browsers_keys, pool_keys) = {
792 let (pool_labels, browsers) = self.state.user_visibility_snapshot();
793 let keys: Vec<String> = browsers.into_iter().map(|(l, _)| l).collect();
794 let count = keys
795 .iter()
796 .filter(|label| {
797 !pool_labels.contains(label.as_str()) && !label.starts_with("browser-pane-")
798 })
799 .count();
800 let pool: Vec<String> = pool_labels.into_iter().collect();
801 (count, keys, pool)
802 };
803
804 // Phase B.9.3 diagnostic — fires for every close (incl.
805 // pane closes). Demoted to debug for production. Enable
806 // via RUST_LOG="info,wrr-trace=debug" to see per-close
807 // gate input when investigating close-cascade issues.
808 tracing::debug!(
809 target: "wrr-trace",
810 "[trace] app-exit gate: closing_label={:?} user_count={} is_browser_pane={} browsers={:?} unpromoted_pool={:?}",
811 label, user_browser_count, self.is_browser_pane, browsers_keys, pool_keys
812 );
813
814 // Phase F.6 — narrate the post-close pool-drain decision for
815 // the launcher's window-cleanup-cascade saga. The saga's
816 // Step 2 terminal: `was_last == true` → `Event::PoolDrained`
817 // (the wrr two-stage cascade below kicked off Stage 1's
818 // pool drain); `was_last == false` → `Event::PoolNotLast`
819 // (other windows remain; pool stays warm). Both close the
820 // saga's `SagaStarted` bracket successfully — the saga's job
821 // is to narrate the decision, not enforce a particular
822 // outcome.
823 //
824 // Same skip-pane gate as `report_panes_reaped` above: the
825 // saga is triggered by `Event::WindowClosed`, which only
826 // fires for non-pane top-level windows. Pane closes don't
827 // start a saga, so the report would be a no-op stray on the
828 // bus.
829 //
830 // Computed here (BEFORE the wrr two-stage cascade below) so
831 // the same condition the cascade gates on is what gets
832 // reported. The boolean flag captures intent — Stage 1 may
833 // not have started yet by the time the report is sent, but
834 // the decision itself is final.
835 if let Some(ref lbl) = label {
836 // Same gate as report_panes_reaped above: skip
837 // browser-pane-* only. window-pool-* labels (promoted)
838 // are tracked windows and need their cleanup events.
839 if !lbl.starts_with("browser-pane-") {
840 let was_last = user_browser_count == 0 && !self.is_browser_pane;
841 crate::launcher_ipc::report_pool_drain_decision(lbl.clone(), was_last);
842 }
843 }
844
845 // ── Phase B.9.3 — two-stage close cascade ─────────────────
846 //
847 // Stage 1: If user_browser_count just dropped to 0 (last
848 // user-visible window closed), POST WM_CLOSE to every pool
849 // browser. Async — the message loop processes the closes on
850 // subsequent iterations. We do NOT call quit_message_loop
851 // here. Calling it from inside on_before_close DEADLOCKS the
852 // UI thread (smoke v0.33.498 confirmed: log line "calling
853 // quit_message_loop now" was last; loop never returned).
854 //
855 // Stage 2: When self.browser_list becomes empty AFTER
856 // removing this browser (i.e. every browser this handler
857 // ever managed has closed), THEN call quit_message_loop.
858 // Matches the canonical cefsimple pattern. By then there
859 // are no other in-flight CEF lifecycle events to deadlock
860 // against. The MAIN client's handler is the only one that
861 // owns top-level windows + pool windows, so this fires
862 // exactly when the entire app's CEF browser inventory is
863 // gone.
864 //
865 // Cross-platform note: the Stage 1 PostMessage is the
866 // Windows path. macOS uses NSWindow.performClose:; Linux
867 // uses X11 WM_DELETE_WINDOW. Same async-close-cascade
868 // semantics on all platforms; only the OS API differs.
869 if user_browser_count == 0 && !self.is_browser_pane {
870 // PR #5 H.5 — flip QuitState Running → Draining via reducer.
871 // Mirrors the pre-PR Phase B.9.3 drain flag: spawn_pool_window
872 // checks `quit_state != Running` (in the reducer's spawn arm)
873 // and skips refill on every subsequent on_pool_window_destroyed
874 // → no new pool browsers added → browsers map can actually
875 // drain. BeginDrain is idempotent — safe if a duplicate
876 // last-close fires.
877 self.state.host_dispatch(
878 crate::reducer::HostCommand::BeginDrain {
879 reason: crate::state::QuitReason::LastWindowClosed,
880 },
881 );
882 tracing::warn!(target: "wrr", "[wrr] quit_state=Draining (drain mode)");
883
884 // Phase H.2.b — reducer-aware iteration with fallback + drift logging.
885 let pool_browsers: Vec<cef::Browser> = self
886 .state
887 .list_browsers()
888 .into_iter()
889 .filter(|(label, _)| label.starts_with("window-pool-"))
890 .map(|(_, b)| b)
891 .collect();
892 tracing::warn!(
893 target: "wrr",
894 "[wrr] stage 1: user_count==0; closing {} pool browser(s)",
895 pool_browsers.len()
896 );
897
898 // Windows path: prefer Win32 PostMessage(WM_CLOSE) —
899 // bypasses CEF's task queue (proven reliable; smoke
900 // v0.33.500+). When window_handle() returns null
901 // (early/late lifecycle), fall through to the
902 // post_task path so the close still happens — without
903 // the fallback, self.browser_list never empties and
904 // Stage 2 never fires. (codex #601 P1.)
905 #[cfg(target_os = "windows")]
906 {
907 use windows_sys::Win32::Foundation::HWND;
908 use windows_sys::Win32::UI::WindowsAndMessaging::{PostMessageW, WM_CLOSE};
909 for (i, mut b) in pool_browsers.into_iter().enumerate() {
910 let hwnd_opt = b.host().and_then(|h| {
911 let wh = h.window_handle();
912 if wh.0.is_null() {
913 None
914 } else {
915 Some(wh.0 as HWND)
916 }
917 });
918 if let Some(hwnd) = hwnd_opt {
919 let ok = unsafe { PostMessageW(hwnd, WM_CLOSE, 0, 0) };
920 tracing::debug!(
921 target: "wrr-trace",
922 "[trace] stage1[{}] PostMessage(hwnd={:p}, WM_CLOSE) ok={}",
923 i, hwnd, ok != 0
924 );
925 } else {
926 // Fallback: defer close_browser via UI task.
927 // Same path as non-Windows so the cascade
928 // still drains.
929 let mut task = ClosePoolBrowserTask::new(b);
930 let posted = cef::post_task(cef::ThreadId::UI, Some(&mut task));
931 tracing::warn!(
932 target: "wrr",
933 "[wrr] stage1[{}] hwnd=null; fell back to post_task(close_browser) posted={}",
934 i, posted != 0
935 );
936 }
937 }
938 }
939
940 // Non-Windows path: defer `close_browser` to the UI
941 // thread via `cef::post_task`. Calling close_browser
942 // inline from inside another browser's on_before_close
943 // hangs the UI thread (CEF re-entrance, smoke
944 // v0.33.497 confirmed on Windows; same constraint on
945 // macOS / Linux per CEF docs). Windows prefers
946 // PostMessage(WM_CLOSE) as the primary path (bypasses
947 // CEF's task queue, which proved unreliable in
948 // late-teardown windows — see
949 // `docs/retro/b9-3-quit-thread-analysis.md`).
950 // (reagent #601 P1.)
951 #[cfg(not(target_os = "windows"))]
952 {
953 for (i, b) in pool_browsers.into_iter().enumerate() {
954 let mut task = ClosePoolBrowserTask::new(b);
955 let posted = cef::post_task(cef::ThreadId::UI, Some(&mut task));
956 tracing::debug!(
957 target: "wrr-trace",
958 "[trace] stage1[{}] post_task(close_browser) posted={}",
959 i, posted != 0
960 );
961 }
962 }
963 }
964
965 // Stage 2: every browser this handler ever managed is now
966 // gone. Safe to call quit_message_loop — no other CEF
967 // lifecycle is in flight that could deadlock with it.
968 if self.browser_list.is_empty() && !self.is_browser_pane {
969 tracing::warn!(
970 target: "wrr",
971 "[wrr] stage 2: self.browser_list.is_empty() — calling quit_message_loop"
972 );
973 quit_message_loop();
974 tracing::warn!(target: "wrr", "[wrr] quit_message_loop returned");
975 } else {
976 // Phase B.7.3.3 — `Event::WindowClosed` +
977 // `Event::WindowInstanceReleased` from the launcher
978 // drive remaining renderers' InstancePanel atoms via the
979 // CEF JS bridge; no sync emit here.
980
981 // Tell the backend to clean up this window's workspace/tabs/shells.
982 // This replaces the JavaScript `beforeunload` handler — running it here
983 // ensures shells die after the CEF browser is gone (not while it's still
984 // alive), so Task Manager keeps them grouped until they exit.
985 if let Some(window_id) = backend_window_id {
986 let web_endpoint = self.state.backend_endpoints.lock().web_endpoint.clone();
987 let auth_key = self.state.auth_key.lock().clone();
988 dlog(&format!("spawning backend_close_window thread for window_id={}", window_id));
989 std::thread::spawn(move || {
990 backend_close_window(&web_endpoint, &auth_key, &window_id);
991 });
992 } else {
993 let warn = format!(
994 "[on_before_close] no backend window ID registered for label={:?} — shells may orphan",
995 label
996 );
997 dlog(&warn);
998 tracing::warn!("{}", warn);
999 }
1000 }
1001
1002 tracing::debug!(
1003 target: "wrr-trace",
1004 "[trace] on_before_close EXIT label={:?} self.browser_list.len()={}",
1005 label, self.browser_list.len()
1006 );
1007 }
1008
1009 /// CEF fires this whenever the browser's loading/history state changes
1010 /// (navigation started, navigation committed, back/forward enabled).
1011 /// `can_go_back` / `can_go_forward` come directly from the navigation
1012 /// controller — no need to query `browser.can_go_back()` (which races
1013 /// with history commit when called from `on_load_end`).
1014 ///
1015 /// For panes: emit `browser-pane-nav-state` so the frontend address
1016 /// bar + back/forward buttons reflect CEF's real history state.
1017 fn on_loading_state_change(
1018 &mut self,
1019 browser: Option<&mut Browser>,
1020 _is_loading: i32,
1021 can_go_back: i32,
1022 can_go_forward: i32,
1023 ) {
1024 if !self.is_browser_pane {
1025 return;
1026 }
1027 if let Some(b) = browser.as_deref() {
1028 crate::browser_pane::callbacks::on_loading_state_change_browser_pane(
1029 &self.state,
1030 b,
1031 can_go_back != 0,
1032 can_go_forward != 0,
1033 );
1034 }
1035 }
1036
1037 fn on_load_end(
1038 &mut self,
1039 browser: Option<&mut Browser>,
1040 frame: Option<&mut Frame>,
1041 _http_status_code: i32,
1042 ) {
1043 // Inject the IPC port into the page after it finishes loading.
1044 // Only inject into the main frame (not iframes).
1045 let Some(frame) = frame else { return };
1046
1047 if frame.is_main() != 1 {
1048 return;
1049 }
1050
1051 // Pane-specific on_load_end work (focus subclass re-install after
1052 // Chromium rebuilds Chrome_RenderWidgetHostHWND on navigation)
1053 // lives in `crate::browser_pane::callbacks` after Phase 4. Returning early
1054 // skips main-only IPC-port injection below.
1055 if self.is_browser_pane {
1056 if let Some(b) = browser.as_deref() {
1057 crate::browser_pane::callbacks::on_load_end_browser_pane(&self.state, b);
1058 }
1059 return;
1060 }
1061
1062 let ipc_token = &self.state.ipc_token;
1063 let js = format!(
1064 "window.__AGENTMUX_IPC_PORT__ = {}; window.__AGENTMUX_IPC_TOKEN__ = '{}';",
1065 self.ipc_port, ipc_token
1066 );
1067 let code = CefString::from(js.as_str());
1068 let url = CefString::from("");
1069 frame.execute_java_script(Some(&code), Some(&url), 0);
1070
1071 let url_str = browser
1072 .as_ref()
1073 .and_then(|b| b.main_frame().map(|f| CefString::from(&f.url()).to_string()))
1074 .unwrap_or_default();
1075 tracing::info!(
1076 "Injected IPC port {} into page: {}",
1077 self.ipc_port,
1078 url_str
1079 );
1080
1081 // Signal the pre-splash to fade out the moment CEF's first frame
1082 // is ready. The launcher created this named event and forwarded
1083 // its name via AGENTMUX_SPLASH_EVENT. OpenEventW + SetEvent is
1084 // fire-and-forget; missing env var means no splash was running.
1085 #[cfg(target_os = "windows")]
1086 {
1087 use windows_sys::Win32::Foundation::CloseHandle;
1088 use windows_sys::Win32::System::Threading::{
1089 OpenEventW, SetEvent, EVENT_MODIFY_STATE,
1090 };
1091 if let Ok(event_name) = std::env::var("AGENTMUX_SPLASH_EVENT") {
1092 let nul: Vec<u16> = format!("{}\0", event_name).encode_utf16().collect();
1093 unsafe {
1094 let ev = OpenEventW(EVENT_MODIFY_STATE, 0, nul.as_ptr());
1095 if !ev.is_null() {
1096 SetEvent(ev);
1097 CloseHandle(ev);
1098 }
1099 }
1100 }
1101 }
1102
1103 // Show window via CEF Views API after content paints.
1104 // All windows (main + secondary) now use CEF Views.
1105 let mut browser_cloned = browser.cloned();
1106 if let Some(bv) = browser_view_get_for_browser(browser_cloned.as_mut()) {
1107 if let Some(window) = bv.window() {
1108 if window.is_visible() == 0 {
1109 window.show();
1110 if let Some(ref mut b) = browser_cloned {
1111 if let Some(host) = b.host() {
1112 host.set_focus(1);
1113 }
1114 }
1115 }
1116 }
1117 }
1118 }
1119
1120 fn on_load_error(
1121 &mut self,
1122 _browser: Option<&mut Browser>,
1123 frame: Option<&mut Frame>,
1124 error_code: Errorcode,
1125 error_text: Option<&CefString>,
1126 failed_url: Option<&CefString>,
1127 ) {
1128 debug_assert_ne!(currently_on(ThreadId::UI), 0);
1129
1130 let error_code_raw = sys::cef_errorcode_t::from(error_code);
1131 if error_code_raw == sys::cef_errorcode_t::ERR_ABORTED {
1132 return;
1133 }
1134
1135 let frame = frame.expect("Frame is None");
1136
1137 // Don't show error pages for sub-frames (iframes) — only for
1138 // the main frame. Without this, an iframe blocked by
1139 // X-Frame-Options replaces the entire app with an error page.
1140 if frame.is_main() != 1 {
1141 return;
1142 }
1143 let error_text = error_text.map(CefString::to_string).unwrap_or_default();
1144 let failed_url = failed_url.map(CefString::to_string).unwrap_or_default();
1145 let error_code_i32 = error_code_raw as i32;
1146
1147 tracing::error!(
1148 "Load error: url={} error={} ({})",
1149 failed_url,
1150 error_text,
1151 error_code_i32
1152 );
1153
1154 // Show a user-friendly error page.
1155 let html = format!(
1156 r#"<!DOCTYPE html>
1157<html>
1158<head>
1159 <meta charset="utf-8">
1160 <style>
1161 body {{
1162 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1163 background: #1e1e2e;
1164 color: #cdd6f4;
1165 display: flex;
1166 justify-content: center;
1167 align-items: center;
1168 height: 100vh;
1169 margin: 0;
1170 }}
1171 .error-container {{
1172 text-align: center;
1173 max-width: 600px;
1174 padding: 40px;
1175 }}
1176 h1 {{ color: #f38ba8; font-size: 24px; }}
1177 p {{ color: #a6adc8; line-height: 1.6; }}
1178 code {{
1179 background: #313244;
1180 padding: 2px 8px;
1181 border-radius: 4px;
1182 font-size: 14px;
1183 }}
1184 .retry {{
1185 margin-top: 20px;
1186 padding: 10px 24px;
1187 background: #89b4fa;
1188 color: #1e1e2e;
1189 border: none;
1190 border-radius: 6px;
1191 cursor: pointer;
1192 font-size: 14px;
1193 }}
1194 </style>
1195</head>
1196<body>
1197 <div class="error-container">
1198 <h1>Failed to load AgentMux frontend</h1>
1199 <p>Could not connect to <code>{failed_url}</code></p>
1200 <p>Error: {error_text} ({error_code_i32})</p>
1201 <p>Make sure the Vite dev server is running:<br>
1202 <code>task dev</code> or <code>npx vite</code></p>
1203 <button class="retry" onclick="location.reload()">Retry</button>
1204 </div>
1205</body>
1206</html>"#
1207 );
1208
1209 let b64 = cef::base64_encode(Some(html.as_bytes()));
1210 let b64_str = CefString::from(&b64).to_string();
1211 let data_uri = format!("data:text/html;base64,{}", b64_str);
1212 let uri = CefString::from(data_uri.as_str());
1213 frame.load_url(Some(&uri));
1214 }
1215
1216 /// Render-process terminated — typically OOM, a renderer-side panic, or
1217 /// some native bug inside CEF/Chromium. Without this hook the window
1218 /// just turns white. We log the cause and replace the white page with
1219 /// a recovery HTML page that offers Reload / Quit buttons.
1220 ///
1221 /// See specs/SPEC_GRACEFUL_CRASH_HANDLING_2026_04_13.md (PR 1).
1222 fn on_render_process_terminated(
1223 &mut self,
1224 browser: Option<&mut Browser>,
1225 status: TerminationStatus,
1226 error_code: i32,
1227 error_string: Option<&CefString>,
1228 ) {
1229 let reason = if status == TerminationStatus::PROCESS_OOM {
1230 "out of memory"
1231 } else if status == TerminationStatus::PROCESS_CRASHED {
1232 "renderer process crashed"
1233 } else if status == TerminationStatus::ABNORMAL_TERMINATION {
1234 "abnormal termination"
1235 } else {
1236 "renderer process terminated"
1237 };
1238
1239 let detail = error_string.map(CefString::to_string).unwrap_or_default();
1240 tracing::error!(
1241 target: "crash",
1242 kind = "renderer_terminated",
1243 reason,
1244 error_code,
1245 detail = %detail,
1246 "{}", reason,
1247 );
1248
1249 // Resolve the real frontend URL so the Reload button can navigate
1250 // back to the live app instead of reloading the recovery page
1251 // itself. Matches the format used by
1252 // commands::window::resolve_frontend_base_url and its callers
1253 // (see window.rs:400, window.rs:430, drag.rs:294 — all use the
1254 // same ipc_port / ipc_token query params).
1255 let base_url = crate::commands::window::resolve_frontend_base_url(self.ipc_port);
1256 let separator = if base_url.contains('?') { "&" } else { "?" };
1257 let app_url = format!(
1258 "{}{}ipc_port={}&ipc_token={}",
1259 base_url, separator, self.ipc_port, self.state.ipc_token
1260 );
1261
1262 let detail_block = if detail.is_empty() {
1263 String::new()
1264 } else {
1265 format!("<p class=\"detail\"><code>{}</code></p>", html_escape(&detail))
1266 };
1267
1268 // Build the recovery page. Plain HTML+CSS, no JS dependencies
1269 // beyond a single click handler, so it renders even if the
1270 // frontend bundle is dead. The Reload button navigates directly
1271 // to the real app URL (NOT location.reload() — that would just
1272 // re-render the same data: URL). CEF will spawn a fresh renderer
1273 // subprocess for the navigation.
1274 //
1275 // NOTE on ipc_token exposure: the token is already present in
1276 // the live app URL that was loaded before the crash (it's in
1277 // the location bar for the dead renderer's process). Embedding
1278 // it in the recovery HTML that runs inside the same browser
1279 // doesn't extend its reach — the HTML is ephemeral, not
1280 // persisted to disk, and `window.close()` or the next crash
1281 // clears it.
1282 let html = format!(
1283 r#"<!DOCTYPE html>
1284<html lang="en">
1285<head>
1286 <meta charset="utf-8">
1287 <title>AgentMux — Recovery</title>
1288 <style>
1289 :root {{
1290 color-scheme: dark;
1291 }}
1292 body {{
1293 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1294 background: #1e1e2e;
1295 color: #cdd6f4;
1296 display: flex;
1297 justify-content: center;
1298 align-items: center;
1299 min-height: 100vh;
1300 margin: 0;
1301 padding: 24px;
1302 box-sizing: border-box;
1303 }}
1304 .recovery {{
1305 text-align: center;
1306 max-width: 540px;
1307 padding: 36px;
1308 background: #181825;
1309 border: 1px solid #313244;
1310 border-radius: 10px;
1311 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
1312 }}
1313 .icon {{
1314 font-size: 36px;
1315 line-height: 1;
1316 margin-bottom: 12px;
1317 }}
1318 h1 {{
1319 color: #f9e2af;
1320 font-size: 22px;
1321 margin: 0 0 6px 0;
1322 }}
1323 .reason {{
1324 color: #a6adc8;
1325 font-size: 14px;
1326 margin: 0 0 20px 0;
1327 font-style: italic;
1328 }}
1329 p {{
1330 color: #bac2de;
1331 line-height: 1.55;
1332 margin: 0 0 12px 0;
1333 font-size: 14px;
1334 }}
1335 .detail code {{
1336 display: inline-block;
1337 background: #313244;
1338 color: #f38ba8;
1339 padding: 6px 10px;
1340 border-radius: 4px;
1341 font-size: 12px;
1342 font-family: ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace;
1343 word-break: break-all;
1344 text-align: left;
1345 max-width: 100%;
1346 }}
1347 .actions {{
1348 display: flex;
1349 gap: 10px;
1350 justify-content: center;
1351 margin-top: 24px;
1352 flex-wrap: wrap;
1353 }}
1354 button {{
1355 padding: 10px 22px;
1356 border: 1px solid #45475a;
1357 border-radius: 6px;
1358 background: #313244;
1359 color: #cdd6f4;
1360 cursor: pointer;
1361 font-size: 13px;
1362 font-family: inherit;
1363 transition: background 0.1s, border-color 0.1s;
1364 }}
1365 button:hover {{
1366 background: #45475a;
1367 border-color: #585b70;
1368 }}
1369 button.primary {{
1370 background: #89b4fa;
1371 color: #1e1e2e;
1372 border-color: #89b4fa;
1373 font-weight: 600;
1374 }}
1375 button.primary:hover {{
1376 background: #74a0f8;
1377 border-color: #74a0f8;
1378 }}
1379 .footer {{
1380 color: #6c7086;
1381 font-size: 11px;
1382 margin-top: 18px;
1383 font-family: ui-monospace, monospace;
1384 }}
1385 </style>
1386</head>
1387<body>
1388 <div class="recovery" role="alertdialog" aria-labelledby="title">
1389 <div class="icon">⚠</div>
1390 <h1 id="title">AgentMux hit a problem</h1>
1391 <p class="reason">Reason: {reason_safe}</p>
1392 {detail_block}
1393 <p>Your open sessions are saved on disk. Reloading will bring you back where you left off.</p>
1394 <div class="actions">
1395 <button class="primary" id="reload-btn">Reload window</button>
1396 <button onclick="window.close()">Quit</button>
1397 </div>
1398 <div class="footer">error_code={error_code}</div>
1399 </div>
1400 <script>
1401 // The Reload button navigates to the live app URL (not
1402 // location.reload, which would just re-render this data: page).
1403 // The URL is injected by the host at HTML-build time.
1404 document.getElementById('reload-btn').addEventListener('click', function() {{
1405 location.href = {app_url_js};
1406 }});
1407 </script>
1408</body>
1409</html>"#,
1410 reason_safe = html_escape(reason),
1411 detail_block = detail_block,
1412 error_code = error_code,
1413 app_url_js = js_string_literal(&app_url),
1414 );
1415
1416 // Load the recovery page in the main frame of the dead browser.
1417 // The renderer subprocess will be re-spawned by CEF when we
1418 // navigate, so the new page mounts in a fresh process.
1419 if let Some(b) = browser {
1420 if let Some(frame) = b.main_frame() {
1421 let b64 = cef::base64_encode(Some(html.as_bytes()));
1422 let b64_str = CefString::from(&b64).to_string();
1423 let data_uri = format!("data:text/html;base64,{}", b64_str);
1424 let uri = CefString::from(data_uri.as_str());
1425 frame.load_url(Some(&uri));
1426 }
1427 }
1428 }
1429
1430 /// CEF asks the embedder for HTTP Basic / Digest credentials on a
1431 /// 401/407. Browser-pane requests get surfaced to the renderer via
1432 /// `browser-pane-auth-required` so the user can type credentials;
1433 /// non-browser-pane requests (the main host window's frontend
1434 /// load) are declined — those shouldn't hit auth-challenged
1435 /// resources, and silently failing matches the prior behavior.
1436 ///
1437 /// Returns 1 (async — we'll resolve the callback via
1438 /// `browser_pane_auth_submit` / `browser_pane_auth_cancel`) or 0
1439 /// (sync no — CEF aborts the request).
1440 ///
1441 /// Phase α of SPEC_BROWSER_PANE_HTTP_BASIC_AUTH_2026_05_18.md.
1442 fn on_auth_credentials(
1443 &mut self,
1444 browser: Option<&mut Browser>,
1445 origin_url: Option<&CefString>,
1446 is_proxy: ::std::os::raw::c_int,
1447 host: Option<&CefString>,
1448 port: ::std::os::raw::c_int,
1449 realm: Option<&CefString>,
1450 _scheme: Option<&CefString>,
1451 callback: Option<&mut AuthCallback>,
1452 ) -> ::std::os::raw::c_int {
1453 // Resolve the pane block_id from the browser ref. If this isn't
1454 // a browser-pane browser (i.e. it's the host frontend's browser),
1455 // we have no UI to prompt — decline and let CEF fail the
1456 // request. The host frontend should never hit auth challenges.
1457 let Some(b) = browser.as_deref() else {
1458 tracing::warn!("[browser-pane-auth] no browser ref — declining");
1459 return 0;
1460 };
1461 let Some(block_id) =
1462 crate::browser_pane::callbacks::resolve_pane_block_id(&self.state, b)
1463 else {
1464 tracing::info!(
1465 "[browser-pane-auth] not a browser pane (host frontend?) — declining"
1466 );
1467 return 0;
1468 };
1469 let Some(cb) = callback else {
1470 return 0;
1471 };
1472
1473 let request_id = uuid::Uuid::new_v4().to_string();
1474 let origin = origin_url.map(CefString::to_string).unwrap_or_default();
1475 let host_str = host.map(CefString::to_string).unwrap_or_default();
1476 let realm_str = realm.map(CefString::to_string).unwrap_or_default();
1477 let is_proxy_bool = is_proxy != 0;
1478
1479 let block_id_short: String = block_id.chars().take(7).collect();
1480 tracing::info!(
1481 "[browser-pane-auth][{}] auth-required origin={:?} host={:?}:{} realm={:?} is_proxy={} request_id={}",
1482 block_id_short, origin, host_str, port, realm_str, is_proxy_bool, request_id,
1483 );
1484
1485 // Park the callback so the renderer's submit/cancel IPC can
1486 // resolve it. The callback IS reference-counted internally
1487 // (RefGuard) — `cb.clone()` bumps the refcount so the registry
1488 // can hold it after CEF's invocation returns. Pass block_id so
1489 // `cancel_for_block` can clean up when the pane closes.
1490 crate::browser_pane::auth::register(
1491 request_id.clone(),
1492 block_id.clone(),
1493 cb.clone(),
1494 );
1495
1496 // Broadcast to every TOP-LEVEL window — `emit_event_from_state`
1497 // only dispatches to the "main" browser, so panes hosted in a
1498 // tear-off / secondary window would never see the event and
1499 // the CEF callback would wait until TTL/cancel. Filtering to
1500 // top-level is critical: the payload carries origin/host/realm
1501 // plus block_id correlation, which must not be visible to
1502 // remote content loaded inside a sibling pane (whose main
1503 // frame is an arbitrary URL). The host renderer filters on
1504 // `payload.block_id` so only the window owning this pane
1505 // surfaces the prompt.
1506 crate::events::emit_event_to_top_level_windows(
1507 &self.state,
1508 "browser-pane-auth-required",
1509 &serde_json::json!({
1510 "block_id": block_id,
1511 "request_id": request_id,
1512 "origin": origin,
1513 "host": host_str,
1514 "port": port,
1515 "realm": realm_str,
1516 "is_proxy": is_proxy_bool,
1517 }),
1518 );
1519
1520 1
1521 }
1522}
1523
1524