agentmux_cef\browser_api/
resolver.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Resolve `block_id` → CDP target id.
5//!
6//! CEF's `/json` endpoint exposes every active page target but does
7//! NOT expose the underlying `cef::Browser::identifier()`, so we can't
8//! match by CEF id directly. Phase 1 strategy:
9//!
10//! 1. Ask the pane manager for the pane's current URL.
11//! 2. Probe `GET http://127.0.0.1:<debug>/json`.
12//! 3. Find the entry whose `url` matches the pane's URL AND whose
13//!    `id` isn't already owned by some other cached block.
14//! 4. Cache `(block_id → target_id)` for subsequent calls.
15//!
16//! Known limit: two panes navigated to the same URL can't be
17//! distinguished this way. Phase-1 consumers (dom-smoke, stress
18//! harness) use distinct URLs to avoid the collision. A later phase
19//! can swap in a snapshot-at-create strategy for bulletproof
20//! one-to-one mapping (see `SPEC_BROWSER_DOM_API.md` §5.5).
21
22use 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    // block_id → target_id
35    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        // Fast path: cache hit.
57        if let Some(cached) = self.entries.lock().get(block_id).cloned() {
58            return Ok(cached);
59        }
60
61        // Determine the pane's current URL on the CEF UI side. The
62        // browser_panes manager exposes this via main_frame().url()
63        // when the pane is Live.
64        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        // Probe /json.
72        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        // Filter:
89        // - type=="page" (skip worker, iframe, etc.)
90        // - url matches the pane's url (exact or trailing-slash-tolerant)
91        // - id not already in our cache (avoids claiming another block's target)
92        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    /// Invalidate a block's cached target — called when a pane closes
124    /// or navigates. On next resolve we'll re-probe.
125    pub fn forget(&self, block_id: &str) {
126        self.entries.lock().remove(block_id);
127    }
128}
129
130fn normalize_url(u: &str) -> String {
131    // `https://www.google.com` vs `https://www.google.com/` are the
132    // same target; CEF /json includes the trailing slash, the pane's
133    // meta.url in our tests usually doesn't.
134    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}