Skip to main content

agentmux_srv/server/
browser_popup.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! New windows a browser pane's page opens
5//! (docs/specs/SPEC_BROWSER_PANE_POPUPS_ADOPTED_2026_10_08.md,
6//! docs/specs/SPEC_BROWSER_PANE_NATIVE_POPUPS_AGENT_DRIVEN_2026_10_08.md).
7//!
8//! Two kinds, by what the page asked for:
9//! - **popup panes** (a link or window asking for a tab or window): srv opens
10//!   a browser pane beside the opener when the host asks
11//!   (`/api/v1/host/browser_popup`); remembered here per opener.
12//! - **popup windows** (`window.open` with popup features): the host creates
13//!   a native CEF popup itself and reports it (`/api/v1/host/browser_popup_window`);
14//!   srv keeps who opened it and which agent, if any, may drive it.
15//!
16//! Which requests are honored is `agentmux_common::popup_rules`, shared with
17//! the host. Everything here is in memory, like ownership: after srv
18//! restarts, popups are ordinary panes and windows nobody drives.
19
20use std::collections::HashMap;
21use std::sync::{Mutex, OnceLock};
22
23pub(crate) use agentmux_common::popup_rules::{decide, origin_of};
24
25/// Block meta on a popup pane: the pane that opened it. Written only by srv.
26pub(crate) const POPUP_OF_META_KEY: &str = "browser:popup_of";
27/// Block meta on a popup pane: the opener's origin, shown as "Popup from …".
28/// Written only by srv.
29pub(crate) const POPUP_FROM_META_KEY: &str = "browser:popup_from";
30/// Block meta on an opener: its open popup windows, `[{id, url}]`, for the
31/// strip with Show and Close. Written only by srv.
32pub(crate) const POPUP_WINDOWS_META_KEY: &str = "browser:popup_windows";
33
34/// Marks a new popup pane's meta with its opener and the opener's origin.
35pub(crate) fn mark_popup_meta(meta: &mut crate::backend::obj::MetaMapType, opener: &str, opener_url: &str) {
36    meta.insert(POPUP_OF_META_KEY.to_string(), serde_json::json!(opener));
37    meta.insert(POPUP_FROM_META_KEY.to_string(), serde_json::json!(origin_of(opener_url)));
38}
39
40/// A popup window's id is the host's label for it.
41pub(crate) fn is_window_id(id: &str) -> bool {
42    id.starts_with("popup-")
43}
44
45// ---- popup panes ----
46
47#[derive(Default)]
48struct Popups {
49    /// opener → its popup panes, oldest first.
50    open: HashMap<String, Vec<String>>,
51    /// opener → popups admitted but still being opened (`Reservation`).
52    pending: HashMap<String, usize>,
53}
54
55fn popups() -> &'static Mutex<Popups> {
56    static POPUPS: OnceLock<Mutex<Popups>> = OnceLock::new();
57    POPUPS.get_or_init(Default::default)
58}
59
60fn lock() -> std::sync::MutexGuard<'static, Popups> {
61    popups().lock().unwrap_or_else(|p| p.into_inner())
62}
63
64/// Drop `opener`'s closed popups (`exists` says whether a block is still
65/// there) and return the open ones.
66fn prune(p: &mut Popups, opener: &str, exists: impl Fn(&str) -> bool) -> Vec<String> {
67    let Some(list) = p.open.get_mut(opener) else {
68        return Vec::new();
69    };
70    list.retain(|id| exists(id));
71    let out = list.clone();
72    if list.is_empty() {
73        p.open.remove(opener);
74    }
75    out
76}
77
78/// A slot for a popup pane being opened, held from the moment it is admitted
79/// until it is open (`commit`) or not (dropped). Counting the slot during the
80/// wait is what keeps two popups admitted at once from both fitting under the
81/// cap.
82pub(crate) struct Reservation {
83    opener: String,
84    held: bool,
85}
86
87impl Reservation {
88    /// The popup pane opened: it counts as open from now on.
89    pub(crate) fn commit(mut self, popup: &str) {
90        let mut p = lock();
91        release(&mut p, &self.opener);
92        p.open.entry(self.opener.clone()).or_default().push(popup.to_string());
93        self.held = false;
94    }
95}
96
97impl Drop for Reservation {
98    fn drop(&mut self) {
99        if self.held {
100            release(&mut lock(), &self.opener);
101        }
102    }
103}
104
105fn release(p: &mut Popups, opener: &str) {
106    if let Some(n) = p.pending.get_mut(opener) {
107        *n = n.saturating_sub(1);
108        if *n == 0 {
109            p.pending.remove(opener);
110        }
111    }
112}
113
114/// Popup panes open or being opened anywhere below `node`: its own, and
115/// theirs, and so on. Records stay with each popup's direct opener, so when a
116/// pane in the middle of a chain closes, the subtree under it still counts
117/// for whichever pane is now its root.
118fn count_tree(p: &mut Popups, node: &str, exists: &dyn Fn(&str) -> bool, depth: usize) -> usize {
119    let kids = prune(p, node, exists);
120    let mut n = kids.len() + p.pending.get(node).copied().unwrap_or(0);
121    if depth < CHAIN_DEPTH_LIMIT {
122        for k in kids {
123            n += count_tree(p, &k, exists, depth + 1);
124        }
125    }
126    n
127}
128
129/// How deep `count_tree` follows popups of popups.
130const CHAIN_DEPTH_LIMIT: usize = 16;
131
132/// Admit one more popup pane from `opener` if `admit` agrees, given how many
133/// popup panes the chain it belongs to (rooted at `root`) has open or being
134/// opened, and hold its slot. The count and the slot are taken under one lock.
135pub(crate) fn reserve<E>(
136    opener: &str,
137    root: &str,
138    exists: impl Fn(&str) -> bool,
139    admit: impl FnOnce(usize) -> Result<(), E>,
140) -> Result<Reservation, E> {
141    let mut p = lock();
142    let taken = count_tree(&mut p, root, &exists, 0);
143    admit(taken)?;
144    *p.pending.entry(opener.to_string()).or_default() += 1;
145    Ok(Reservation { opener: opener.to_string(), held: true })
146}
147
148/// Remember that `opener` opened `popup` (outside `reserve`; used by tests).
149#[cfg(test)]
150pub(crate) fn remember(opener: &str, popup: &str) {
151    lock().open.entry(opener.to_string()).or_default().push(popup.to_string());
152}
153
154/// The popups `opener` has open, oldest first. `exists` says whether a block
155/// is still there; closed ones are dropped from the record as they're found.
156pub(crate) fn open_popups(opener: &str, exists: impl Fn(&str) -> bool) -> Vec<String> {
157    prune(&mut lock(), opener, exists)
158}
159
160// ---- popup windows ----
161
162#[derive(Clone, Debug, PartialEq, Eq)]
163pub(crate) struct PopupWindow {
164    /// The browser pane whose page opened it.
165    pub opener: String,
166    /// Its address, as last reported.
167    pub url: String,
168    /// The agent that owned the opener when it opened, if any. Driving it
169    /// also needs that agent to still own the opener (`ui_handlers`).
170    pub owner: Option<String>,
171    seq: u64,
172}
173
174fn windows() -> &'static Mutex<(u64, HashMap<String, PopupWindow>)> {
175    static WINDOWS: OnceLock<Mutex<(u64, HashMap<String, PopupWindow>)>> = OnceLock::new();
176    WINDOWS.get_or_init(Default::default)
177}
178
179/// Record a popup window the host created. `owner` is the opener's owner
180/// from srv's own record, never anything the host or the page said.
181pub(crate) fn window_opened(id: &str, opener: &str, url: &str, owner: Option<String>) {
182    let mut g = windows().lock().unwrap_or_else(|p| p.into_inner());
183    g.0 += 1;
184    let seq = g.0;
185    g.1.insert(
186        id.to_string(),
187        PopupWindow { opener: opener.to_string(), url: url.to_string(), owner, seq },
188    );
189}
190
191/// The popup window navigated. Returns its opener, if it is known.
192pub(crate) fn window_navigated(id: &str, url: &str) -> Option<String> {
193    let mut g = windows().lock().unwrap_or_else(|p| p.into_inner());
194    let w = g.1.get_mut(id)?;
195    w.url = url.to_string();
196    Some(w.opener.clone())
197}
198
199/// The popup window closed. Returns its opener, if it was known.
200pub(crate) fn window_closed(id: &str) -> Option<String> {
201    windows()
202        .lock()
203        .unwrap_or_else(|p| p.into_inner())
204        .1
205        .remove(id)
206        .map(|w| w.opener)
207}
208
209/// Forget the popup windows whose opener `ours` recognises (the host
210/// registered anew; see `ui_handlers::reset_popup_windows`).
211pub(crate) fn clear_windows(ours: impl Fn(&str) -> bool) {
212    windows().lock().unwrap_or_else(|p| p.into_inner()).1.retain(|_, w| !ours(&w.opener));
213}
214
215pub(crate) fn window(id: &str) -> Option<PopupWindow> {
216    windows().lock().unwrap_or_else(|p| p.into_inner()).1.get(id).cloned()
217}
218
219/// `opener`'s open popup windows, oldest first.
220pub(crate) fn windows_of(opener: &str) -> Vec<(String, PopupWindow)> {
221    let g = windows().lock().unwrap_or_else(|p| p.into_inner());
222    let mut out: Vec<_> = g
223        .1
224        .iter()
225        .filter(|(_, w)| w.opener == opener)
226        .map(|(id, w)| (id.clone(), w.clone()))
227        .collect();
228    out.sort_by_key(|(_, w)| w.seq);
229    out
230}
231
232/// The opener's popup-windows strip, as written to its meta.
233pub(crate) fn strip_value(opener: &str) -> serde_json::Value {
234    serde_json::Value::Array(
235        windows_of(opener)
236            .into_iter()
237            .map(|(id, w)| serde_json::json!({ "id": id, "url": w.url }))
238            .collect(),
239    )
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn a_held_slot_counts_against_the_cap_until_it_opens_or_is_dropped() {
248        let opener = "test-reserve-opener";
249        let cap = |taken: usize| if taken < 2 { Ok(()) } else { Err(taken) };
250        // Two admitted at once: both slots count, so a third doesn't fit,
251        // although nothing is open yet.
252        let a = reserve(opener, opener, |_| true, cap).unwrap();
253        let b = reserve(opener, opener, |_| true, cap).unwrap();
254        assert_eq!(reserve(opener, opener, |_| true, cap).err(), Some(2));
255        // One opens, the other fails: the failed one's slot comes back.
256        a.commit("test-reserve-p1");
257        drop(b);
258        assert_eq!(open_popups(opener, |_| true), vec!["test-reserve-p1"]);
259        let c = reserve(opener, opener, |_| true, cap).unwrap();
260        assert_eq!(reserve(opener, opener, |_| true, cap).err(), Some(2));
261        drop(c);
262        // A closed popup frees its slot too.
263        assert!(reserve(opener, opener, |id| id != "test-reserve-p1", cap).is_ok());
264        assert!(open_popups(opener, |_| true).is_empty());
265    }
266
267    #[test]
268    fn a_chain_counts_together_and_survives_its_root_closing() {
269        // R opened A; A opened B.
270        remember("test-tree-r", "test-tree-a");
271        remember("test-tree-a", "test-tree-b");
272        let count = |root: &str, exists: &dyn Fn(&str) -> bool| {
273            let mut taken = None;
274            let _ = reserve("test-tree-probe", root, |id| exists(id), |n| {
275                taken = Some(n);
276                Err::<(), ()>(())
277            });
278            taken.unwrap()
279        };
280        assert_eq!(count("test-tree-r", &|_| true), 2);
281        // R closed: A is the root now, and B still counts for it.
282        assert_eq!(count("test-tree-a", &|id| id != "test-tree-r"), 1);
283        assert_eq!(open_popups("test-tree-a", |_| true), vec!["test-tree-b"]);
284        open_popups("test-tree-r", |_| false);
285        open_popups("test-tree-a", |_| false);
286    }
287
288    #[test]
289    fn open_popups_drops_closed_ones() {
290        remember("test-popup-opener", "p1");
291        remember("test-popup-opener", "p2");
292        assert_eq!(open_popups("test-popup-opener", |_| true), vec!["p1", "p2"]);
293        assert_eq!(open_popups("test-popup-opener", |p| p == "p2"), vec!["p2"]);
294        assert!(open_popups("test-popup-opener", |_| false).is_empty());
295        assert!(open_popups("test-popup-opener", |_| true).is_empty());
296        assert!(open_popups("test-popup-unknown", |_| true).is_empty());
297    }
298
299    #[test]
300    fn a_window_is_recorded_listed_in_order_followed_and_forgotten() {
301        let opener = "test-window-opener";
302        window_opened("popup-test-a", opener, "https://signin.example.com/a", Some("lark".into()));
303        window_opened("popup-test-b", opener, "https://signin.example.com/b", None);
304        window_opened("popup-test-other", "test-window-other-opener", "https://x.example.com/", None);
305        let ids: Vec<_> = windows_of(opener).into_iter().map(|(id, _)| id).collect();
306        assert_eq!(ids, vec!["popup-test-a", "popup-test-b"]);
307        assert_eq!(window("popup-test-a").unwrap().owner.as_deref(), Some("lark"));
308
309        assert_eq!(window_navigated("popup-test-a", "https://signin.example.com/done").as_deref(), Some(opener));
310        assert_eq!(
311            strip_value(opener),
312            serde_json::json!([
313                { "id": "popup-test-a", "url": "https://signin.example.com/done" },
314                { "id": "popup-test-b", "url": "https://signin.example.com/b" },
315            ])
316        );
317
318        assert_eq!(window_closed("popup-test-a").as_deref(), Some(opener));
319        assert_eq!(window_closed("popup-test-a"), None);
320        assert_eq!(window_navigated("popup-test-a", "https://x/"), None);
321        assert_eq!(windows_of(opener).len(), 1);
322        window_closed("popup-test-b");
323        window_closed("popup-test-other");
324        assert_eq!(strip_value(opener), serde_json::json!([]));
325    }
326
327    #[test]
328    fn window_ids_are_the_hosts_popup_labels() {
329        assert!(is_window_id("popup-3f2c"));
330        assert!(!is_window_id("3f2c-block-id"));
331    }
332}