Skip to main content

zendriver_transport/
observer.rs

1//! [`TargetObserver`] trait — fires on each new attached target while the
2//! target is paused at the debugger.
3
4use crate::connection::Connection;
5use crate::error::CallError;
6
7/// Observer fired on every new [`Target.attachedToTarget`] event before the
8/// debugger releases the target.
9///
10/// The actor walks every registered observer serially (registration order)
11/// on each new target. A failing observer returns `Err` and the actor detaches
12/// the session via `Target.detachFromTarget`; observers that exceed the
13/// observer-timeout are skipped and the actor releases the debugger so Chrome
14/// doesn't hang indefinitely.
15///
16/// `zendriver-stealth::StealthObserver` implements this trait to install
17/// patches on every new page target before the page's first script runs.
18///
19/// [`Target.attachedToTarget`]: https://chromedevtools.github.io/devtools-protocol/tot/Target/#event-attachedToTarget
20#[async_trait::async_trait]
21pub trait TargetObserver: Send + Sync {
22    /// Called once per new target, after attach and before debugger release.
23    /// Observer MUST complete and return before the target resumes execution.
24    /// Observers run serially in registration order; returning Err leaves the
25    /// target paused (the actor logs + force-detaches the session).
26    async fn on_target_attached(&self, session: PausedSession<'_>) -> Result<(), ObserverError>;
27
28    /// Called when a target detaches. Default: no-op.
29    async fn on_target_detached(&self, _session_id: &str) {}
30
31    /// Stable identifier used in actor diagnostics (`error!` / `warn!` records).
32    fn name(&self) -> &'static str;
33}
34
35/// Scope passed to [`TargetObserver::on_target_attached`] — a session that's
36/// currently paused at the debugger, plus a back-reference to the connection
37/// for CDP calls scoped to that session.
38#[derive(Debug)]
39pub struct PausedSession<'a> {
40    /// CDP `sessionId` for the newly attached target.
41    pub session_id: &'a str,
42    /// Decoded `targetInfo` payload (target id, kind, url, ...).
43    pub target_info: &'a TargetInfo,
44    pub(crate) conn: &'a Connection,
45}
46
47impl<'a> PausedSession<'a> {
48    /// Send a CDP command scoped to this paused session's `sessionId`.
49    /// Convenience over reaching for [`PausedSession::connection`] manually.
50    pub async fn call(
51        &self,
52        method: impl Into<String>,
53        params: serde_json::Value,
54    ) -> Result<serde_json::Value, CallError> {
55        self.conn
56            .call_raw(method, params, Some(self.session_id.to_string()))
57            .await
58    }
59
60    /// The underlying [`Connection`]. Observers that need to spawn
61    /// additional [`crate::SessionHandle`]s (e.g. zendriver's
62    /// `TabRegistrar`) clone this to bind a fresh handle for the newly
63    /// attached `sessionId`.
64    #[must_use]
65    pub fn connection(&self) -> &'a Connection {
66        self.conn
67    }
68}
69
70/// Errors an observer may return to indicate it failed to set up its slice of
71/// the new target.
72#[derive(Debug, thiserror::Error)]
73#[non_exhaustive]
74pub enum ObserverError {
75    /// A CDP call dispatched from inside the observer failed.
76    #[error("call failed: {0}")]
77    Call(#[from] CallError),
78
79    /// The observer exceeded its per-target timeout. The actor surfaces this
80    /// when constructing diagnostic output; observers don't construct it
81    /// themselves.
82    #[error("observer timed out after {0:?}")]
83    Timeout(std::time::Duration),
84
85    /// The observer panicked. Carries the downcast panic payload.
86    #[error("observer panicked: {0}")]
87    Panicked(String),
88
89    /// Catch-all for observer-defined failures that don't fit the typed
90    /// variants above.
91    #[error("{0}")]
92    Other(String),
93}
94
95/// Decoded `targetInfo` payload from `Target.attachedToTarget` / `targetCreated`.
96///
97/// Mirrors CDP's [`Target.TargetInfo`] but only deserializes the fields used
98/// downstream by observers + zendriver core.
99///
100/// [`Target.TargetInfo`]: https://chromedevtools.github.io/devtools-protocol/tot/Target/#type-TargetInfo
101#[derive(Debug, Clone, serde::Deserialize)]
102pub struct TargetInfo {
103    /// CDP target id (stable across `attach` / `detach` cycles).
104    #[serde(rename = "targetId")]
105    pub target_id: String,
106    /// Target kind (`"page"`, `"iframe"`, `"worker"`, ...). The stealth
107    /// observer keys off this to skip workers + iframes.
108    #[serde(rename = "type")]
109    pub kind: String,
110    /// Initial URL the target is at — typically `about:blank` at attach time.
111    pub url: String,
112    /// Document title, when present.
113    #[serde(default)]
114    pub title: Option<String>,
115    /// Whether a debugger is currently attached.
116    #[serde(default)]
117    pub attached: bool,
118    /// Browser-context id this target belongs to (incognito / profile split).
119    #[serde(default, rename = "browserContextId")]
120    pub browser_context_id: Option<String>,
121    /// `frameId` of the iframe element that hosts this target, when present.
122    /// Chrome populates this for `kind == "iframe"` OOPIF targets (Chromium
123    /// 90+); used by [`crate::TargetObserver`] implementations to attach the
124    /// OOPIF's child session to its hosting frame in the parent tab's frame
125    /// tree. Not present for `kind == "page"` and may be absent on older
126    /// Chromium versions even for iframe targets, in which case attach
127    /// observers fall back to matching `target_id` against the frame tree.
128    #[serde(default, rename = "openerFrameId")]
129    pub opener_frame_id: Option<String>,
130}
131
132#[cfg(test)]
133#[allow(clippy::panic, clippy::unwrap_used)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn display_observer_error_timeout_includes_duration() {
139        let e = ObserverError::Timeout(std::time::Duration::from_secs(5));
140        assert_eq!(e.to_string(), "observer timed out after 5s");
141    }
142
143    #[test]
144    fn display_observer_error_panicked_includes_message() {
145        let e = ObserverError::Panicked("oh no".into());
146        assert_eq!(e.to_string(), "observer panicked: oh no");
147    }
148
149    #[test]
150    fn target_info_deserializes_chrome_payload() {
151        let json = r#"{"targetId":"T1","type":"page","url":"about:blank","attached":true}"#;
152        let info: TargetInfo = serde_json::from_str(json).unwrap();
153        assert_eq!(info.target_id, "T1");
154        assert_eq!(info.kind, "page");
155        assert_eq!(info.url, "about:blank");
156        assert!(info.attached);
157    }
158}