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