1use std::collections::HashMap;
21use std::sync::{Mutex, OnceLock};
22
23pub(crate) use agentmux_common::popup_rules::{decide, origin_of};
24
25pub(crate) const POPUP_OF_META_KEY: &str = "browser:popup_of";
27pub(crate) const POPUP_FROM_META_KEY: &str = "browser:popup_from";
30pub(crate) const POPUP_WINDOWS_META_KEY: &str = "browser:popup_windows";
33
34pub(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
40pub(crate) fn is_window_id(id: &str) -> bool {
42 id.starts_with("popup-")
43}
44
45#[derive(Default)]
48struct Popups {
49 open: HashMap<String, Vec<String>>,
51 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
64fn 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
78pub(crate) struct Reservation {
83 opener: String,
84 held: bool,
85}
86
87impl Reservation {
88 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
114fn 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
129const CHAIN_DEPTH_LIMIT: usize = 16;
131
132pub(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#[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
154pub(crate) fn open_popups(opener: &str, exists: impl Fn(&str) -> bool) -> Vec<String> {
157 prune(&mut lock(), opener, exists)
158}
159
160#[derive(Clone, Debug, PartialEq, Eq)]
163pub(crate) struct PopupWindow {
164 pub opener: String,
166 pub url: String,
168 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
179pub(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
191pub(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
199pub(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
209pub(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
219pub(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
232pub(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 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 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 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 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 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}