agentmux_cef\browser_api/
resolver.rs1use std::collections::HashMap;
23use std::sync::Arc;
24
25use parking_lot::Mutex;
26use serde::Deserialize;
27
28use crate::state::AppState;
29
30pub type ResolveError = String;
31
32#[derive(Default)]
33pub struct TargetCache {
34 entries: Mutex<HashMap<String, String>>,
36}
37
38#[derive(Debug, Deserialize)]
39struct JsonTarget {
40 id: String,
41 url: String,
42 #[serde(default, rename = "type")]
43 kind: String,
44}
45
46impl TargetCache {
47 pub fn new() -> Self {
48 Self::default()
49 }
50
51 pub async fn resolve(
52 &self,
53 state: &Arc<AppState>,
54 block_id: &str,
55 ) -> Result<String, ResolveError> {
56 if let Some(cached) = self.entries.lock().get(block_id).cloned() {
58 return Ok(cached);
59 }
60
61 let pane_url = state
65 .browser_panes
66 .pane_url(state, block_id)
67 .ok_or_else(|| {
68 format!("UNKNOWN_BLOCK_ID: no live browser pane for block_id={block_id}")
69 })?;
70
71 let debug_port = *state.debug_port.lock();
73 if debug_port == 0 {
74 return Err("CEF debug port not yet configured".to_string());
75 }
76 let json_url = format!("http://127.0.0.1:{debug_port}/json");
77 let resp = reqwest::get(&json_url)
78 .await
79 .map_err(|e| format!("GET {json_url}: {e}"))?;
80 if !resp.status().is_success() {
81 return Err(format!("{json_url} returned {}", resp.status()));
82 }
83 let targets: Vec<JsonTarget> = resp
84 .json()
85 .await
86 .map_err(|e| format!("parse /json: {e}"))?;
87
88 let already_cached: Vec<String> = self
93 .entries
94 .lock()
95 .values()
96 .cloned()
97 .collect();
98
99 let pane_url_norm = normalize_url(&pane_url);
100 let candidate = targets
101 .iter()
102 .filter(|t| t.kind == "page" || t.kind.is_empty())
103 .filter(|t| !already_cached.contains(&t.id))
104 .find(|t| normalize_url(&t.url) == pane_url_norm);
105
106 let target_id = match candidate {
107 Some(t) => t.id.clone(),
108 None => {
109 return Err(format!(
110 "UNKNOWN_BLOCK_ID: no unclaimed CDP target matches url={pane_url} \
111 for block_id={block_id} (found {} targets)",
112 targets.len()
113 ))
114 }
115 };
116
117 self.entries
118 .lock()
119 .insert(block_id.to_string(), target_id.clone());
120 Ok(target_id)
121 }
122
123 pub fn forget(&self, block_id: &str) {
126 self.entries.lock().remove(block_id);
127 }
128}
129
130fn normalize_url(u: &str) -> String {
131 let trimmed = u.trim_end_matches('/').to_string();
135 trimmed.to_ascii_lowercase()
136}
137
138#[cfg(test)]
139mod tests {
140 use super::normalize_url;
141
142 #[test]
143 fn normalize_strips_trailing_slash_and_lowercases() {
144 assert_eq!(
145 normalize_url("https://www.google.com/"),
146 normalize_url("https://WWW.GOOGLE.COM"),
147 );
148 }
149}