Skip to main content

glass/browser/session/
target.rs

1//! Page target discovery, selection, and creation.
2//!
3//! Lists open page targets, creates new ones, and provides explicit
4//! target selection for subsequent operations.
5
6use super::*;
7
8impl BrowserSession {
9    /// List all open page targets in the browser.
10    ///
11    /// Returns the target ID, URL, title, opener relationship, and whether
12    /// each target is currently active. Capped at 32 targets.
13    pub async fn list_targets(&self) -> BrowserResult<Vec<PageTargetInfo>> {
14        let raw = self.cdp.send_browser("Target.getTargets", None).await?;
15        let active = self.topology.lock().await.active_target_id.clone();
16        let mut targets = Vec::new();
17        for info in raw["targetInfos"].as_array().into_iter().flatten() {
18            if info["type"].as_str() != Some("page") {
19                continue;
20            }
21            let Some(id) = info["targetId"].as_str() else {
22                continue;
23            };
24            validate_topology_id(id)?;
25            if targets.len() == TOPOLOGY_MAX_TARGETS {
26                return Err("page target limit exceeded".into());
27            }
28            targets.push(PageTargetInfo {
29                id: id.to_string(),
30                url: bounded_topology_text(info["url"].as_str().unwrap_or_default()),
31                title: bounded_topology_text(info["title"].as_str().unwrap_or_default()),
32                opener_id: retained_optional_topology_id(info["openerId"].as_str())?,
33                active: active.as_deref() == Some(id),
34            });
35        }
36        self.topology.lock().await.targets = targets.clone();
37        Ok(targets)
38    }
39
40    /// Return recent topology change events for diagnostic purposes.
41    ///
42    /// Each event includes a sequence number, kind (e.g. `targetCreated`),
43    /// and the affected target/frame ID. Bounded to 64 recent events.
44    pub async fn topology_events(&self) -> Vec<TopologyEventSummary> {
45        self.topology.lock().await.events.iter().cloned().collect()
46    }
47
48    pub(crate) async fn route_identity(&self) -> BrowserResult<(String, String)> {
49        if let Some(identity) = self.cdp.operation_identity() {
50            return Ok(identity);
51        }
52
53        let topology = self.topology.lock().await;
54        let (kind, message) = match (
55            topology.active_target_id.as_ref(),
56            topology.active_session_id.as_ref(),
57            topology.active_frame_id.as_ref(),
58        ) {
59            (None, _, _) => (
60                TopologyErrorKind::NoTargetSelected,
61                "no active target is selected; call listTargets to discover available pages",
62            ),
63            (Some(_), None, _) => (
64                TopologyErrorKind::NoPageSession,
65                "active target has no CDP session; the session may need to be re-established",
66            ),
67            (Some(_), Some(_), None) => (
68                TopologyErrorKind::StaleFrame,
69                "active target has no selected frame; call listFrames and selectFrame",
70            ),
71            (Some(_), Some(_), Some(_)) => (
72                TopologyErrorKind::RoutingLost,
73                "active target/frame routing is unavailable; re-synchronize the session",
74            ),
75        };
76        Err(TopologyError::new(kind, message).into())
77    }
78
79    pub(crate) async fn ensured_route_identity(&self) -> BrowserResult<(String, String)> {
80        if let Ok(route) = self.route_identity().await {
81            return Ok(route);
82        }
83        let main_frame = self
84            .list_frames()
85            .await?
86            .into_iter()
87            .find(|frame| frame.parent_id.is_none())
88            .ok_or("active target returned no main frame")?;
89        self.select_frame(&main_frame.id).await?;
90        self.route_identity().await
91    }
92
93    /// Open a new page target navigating to the given URL.
94    ///
95    /// The URL is normalized and validated against the active policy.
96    /// The new target is discoverable via [`list_targets`](Self::list_targets)
97    /// but does not become the active target automatically.
98    pub async fn create_target(&self, url: &str) -> BrowserResult<PageTargetInfo> {
99        let url = normalize_url(url);
100        self.policy.require_url(&url).await?;
101        let result = self
102            .cdp
103            .send_browser("Target.createTarget", Some(serde_json::json!({"url": url})))
104            .await?;
105        let id = result["targetId"]
106            .as_str()
107            .ok_or("Target.createTarget returned no targetId")?;
108        validate_topology_id(id)?;
109        let targets = self.list_targets().await?;
110        let target = targets
111            .into_iter()
112            .find(|target| target.id == id)
113            .ok_or_else(|| -> Box<dyn Error> { "created target was not discoverable".into() })?;
114        self.record_audit("attach", &url);
115        Ok(target)
116    }
117
118    /// Select a page target as the active context.
119    ///
120    /// Attaches to the target via CDP, selects its main frame, and detaches
121    /// from the previously active target. Invalidates the observation cache.
122    pub async fn select_target(&self, target_id: &str) -> BrowserResult<PageTargetInfo> {
123        validate_topology_id(target_id)?;
124        let target = self
125            .list_targets()
126            .await?
127            .into_iter()
128            .find(|target| target.id == target_id)
129            .ok_or("page target was not found")?;
130        let attached = self
131            .cdp
132            .send_browser(
133                "Target.attachToTarget",
134                Some(serde_json::json!({"targetId": target_id, "flatten": true})),
135            )
136            .await?;
137        let new_session = attached["sessionId"]
138            .as_str()
139            .ok_or("Target.attachToTarget returned no sessionId")?
140            .to_string();
141        let old_session = self.topology.lock().await.active_session_id.clone();
142        if let Err(error) = self.cdp.enable_observation_events_for(&new_session).await {
143            let _ = self
144                .cdp
145                .send_browser(
146                    "Target.detachFromTarget",
147                    Some(serde_json::json!({"sessionId": new_session})),
148                )
149                .await;
150            return Err(error.into());
151        }
152        if let Some(interception) = &self.policy_interception {
153            if let Err(error) = enable_fetch_for(&self.cdp, &new_session).await {
154                let _ = self
155                    .cdp
156                    .send_browser(
157                        "Target.detachFromTarget",
158                        Some(serde_json::json!({"sessionId": new_session})),
159                    )
160                    .await;
161                return Err(error);
162            }
163            interception
164                .sessions
165                .lock()
166                .await
167                .insert(new_session.clone());
168        }
169        if let Err(error) = self
170            .cdp
171            .send_to_session(
172                &new_session,
173                "Target.setAutoAttach",
174                Some(serde_json::json!({
175                    "autoAttach": true,
176                    "waitForDebuggerOnStart": matches!(
177                        self.policy.preset(),
178                        PolicyPreset::Hardened | PolicyPreset::UntrustedMcp
179                    ),
180                    "flatten": true
181                })),
182            )
183            .await
184        {
185            let _ = self
186                .cdp
187                .send_browser(
188                    "Target.detachFromTarget",
189                    Some(serde_json::json!({"sessionId": new_session})),
190                )
191                .await;
192            return Err(error.into());
193        }
194        let prepared = async {
195            let raw_frames = self
196                .cdp
197                .send_to_session(&new_session, "Page.getFrameTree", None)
198                .await?;
199            let mut frames = Vec::new();
200            collect_frames(&raw_frames["frameTree"], None, None, &mut frames)?;
201            let main_frame = frames
202                .iter()
203                .find(|frame| frame.parent_id.is_none())
204                .ok_or("selected target returned no main frame")?
205                .id
206                .clone();
207            Ok::<_, Box<dyn Error>>((frames, main_frame))
208        }
209        .await;
210        let (mut frames, main_frame) = match prepared {
211            Ok(prepared) => prepared,
212            Err(error) => {
213                let _ = self
214                    .cdp
215                    .send_browser(
216                        "Target.detachFromTarget",
217                        Some(serde_json::json!({"sessionId": new_session})),
218                    )
219                    .await;
220                return Err(error);
221            }
222        };
223        for frame in &mut frames {
224            frame.active = frame.id == main_frame;
225        }
226        {
227            let mut topology = self.topology.lock().await;
228            topology.active_target_id = Some(target_id.to_string());
229            topology.active_session_id = Some(new_session.clone());
230            topology.active_target_session_id = Some(new_session.clone());
231            topology.active_frame_id = Some(main_frame.clone());
232            topology.frames = frames;
233        }
234        self.cdp.set_active_target_route(
235            Some(target_id.to_string()),
236            Some(new_session.clone()),
237            Some(main_frame),
238            None,
239        );
240        if let Some(old_session) = old_session {
241            let _ = self
242                .cdp
243                .send_browser(
244                    "Target.detachFromTarget",
245                    Some(serde_json::json!({"sessionId": old_session})),
246                )
247                .await;
248        }
249        self.invalidate_observation();
250        Ok(PageTargetInfo {
251            active: true,
252            ..target
253        })
254    }
255
256    /// Close a page target by ID.
257    ///
258    /// If the closed target was the active target, the session's active
259    /// target, session, and frame state are cleared.
260    pub async fn close_target(&self, target_id: &str) -> BrowserResult<()> {
261        validate_topology_id(target_id)?;
262        let result = self
263            .cdp
264            .send_browser(
265                "Target.closeTarget",
266                Some(serde_json::json!({"targetId": target_id})),
267            )
268            .await?;
269        if result["success"].as_bool() != Some(true) {
270            return Err("Chrome refused to close target".into());
271        }
272        let mut topology = self.topology.lock().await;
273        if topology.active_target_id.as_deref() == Some(target_id) {
274            topology.active_target_id = None;
275            topology.active_session_id = None;
276            topology.active_target_session_id = None;
277            topology.active_frame_id = None;
278            topology.frames.clear();
279            topology.frame_sessions.clear();
280            topology.frame_parents.clear();
281            self.cdp.set_active_target_route(None, None, None, None);
282        }
283        topology.targets.retain(|target| target.id != target_id);
284        Ok(())
285    }
286}