slicc-cdp-client 1.41.21

Shared CDP connection for slicc-cdp CLIs
Documentation
#[derive(Clone, PartialEq)]
struct Dialog {
    url: String,
    frame: String,
    epoch: u64,
}

#[derive(Clone, PartialEq)]
struct DialogDocument {
    url: String,
    loader: String,
}

pub const DIALOG_UNOBSERVED: &str = "Error: No dialog visible to this session. Dialogs already open before attachment require the foreground dialog holder (follow-up #529); arm dialog-accept or dialog-dismiss before triggering a dialog.\n";

impl Cdp {
    pub fn dialog_targets(&mut self) -> Result<Vec<String>, String> {
        let result = self.raw_call("Target.getTargets", json!({}), None, DOCUMENT_CHECK)?;
        let infos = result.get("targetInfos").and_then(Value::as_array).ok_or_else(|| self.refusal())?;
        Ok(infos.iter().filter(|info| {
            info.get("type").and_then(Value::as_str) == Some("page")
                && info.get("url").and_then(Value::as_str).is_some_and(|url| self.dialog_url(url))
                && !self.info_protected(info, infos)
        }).filter_map(|info| info.get("targetId").and_then(Value::as_str).filter(|id| !id.is_empty()).map(str::to_string)).collect())
    }

    pub fn attach_dialog(&mut self, target: &str) -> Result<String, String> {
        self.dialog_target(target)?;
        let result = self.raw_call("Target.attachToTarget", json!({"targetId":target,"flatten":true}), None, DOCUMENT_CHECK)?;
        let session = result.get("sessionId").and_then(Value::as_str).filter(|session| !session.is_empty())
            .ok_or("Target.attachToTarget returned no sessionId\n".to_string())?.to_string();
        self.session_targets.insert(session.clone(), target.to_string());
        self.dialog_sessions.insert(session.clone());
        let observed = (|| {
            let url = self.dialog_target(target)?;
            if self.dialog_history(&session)? != url { return Err(self.refusal()) }
            self.send("Page.enable", json!({}), Some(&session))?;
            Ok(())
        })();
        if let Err(message) = observed {
            let _ = self.raw_call("Target.detachFromTarget", json!({"sessionId":session}), None, DOCUMENT_CHECK);
            self.dialog_sessions.remove(&session);
            self.dialogs.remove(&session);
            self.dialog_documents.remove(&session);
            self.session_targets.remove(&session);
            return Err(message);
        }
        Ok(session)
    }

    pub fn dialog_ready(&mut self, session: &str) -> Result<Value, String> {
        if !self.dialog_sessions.contains(session) || self.blocked.contains(session) { return Err(self.refusal()) }
        let target = self.session_target(session).ok_or_else(|| self.refusal())?;
        let url = self.dialog_target(&target)?;
        if self.dialog_history(session)? != url { return Err(self.refusal()) }
        let loader = self.dialog_documents.get(session).filter(|document| document.url == url).map(|document| document.loader.as_str());
        Ok(json!({"target":target,"loaderId":loader,"loaderIdSource":if loader.is_some() {"frame-navigated"} else {"unavailable"},"status":if loader.is_some() {"document-verified"} else {"observing"}}))
    }

    pub fn dialog_attached(&self, session: &str, target: &str) -> bool {
        self.dialog_sessions.contains(session) && self.session_target(session).as_deref() == Some(target)
    }

    pub fn dialog_id(&self, session: &str) -> Option<u64> {
        self.dialogs.get(session).map(|dialog| dialog.epoch)
    }

    pub fn verify_dialog(&mut self, session: &str, epoch: u64) -> Result<(), String> {
        if self.dialog_id(session) != Some(epoch) { return Err(DIALOG_UNOBSERVED.into()) }
        self.check_dialog(session)
    }

    pub fn wait_dialog(&mut self, session: &str, timeout: Duration) -> Result<(), String> {
        let deadline = Instant::now() + timeout;
        loop {
            if self.blocked.contains(session) || !self.dialog_sessions.contains(session) {
                return Err(self.refusal());
            }
            if let Some(dialog) = self.dialogs.get(session) {
                let target = self.session_target(session).ok_or_else(|| self.refusal())?;
                if dialog.frame != target || !self.dialog_url(&dialog.url) { return Err(self.refusal()) }
                return Ok(());
            }
            if Instant::now() >= deadline || !self.pump(deadline)? {
                return Err(DIALOG_UNOBSERVED.into());
            }
        }
    }

    fn dialog_url(&self, text: &str) -> bool {
        url::Url::parse(text).is_ok_and(|url| matches!(url.scheme(), "http" | "https") && url.host_str().is_some())
            && !self.guard.url(text)
    }

    fn dialog_target(&mut self, target: &str) -> Result<String, String> {
        if target.is_empty() || self.protected_targets.contains(target) || self.abandoned.contains(target) {
            return Err(self.refusal());
        }
        let result = self.raw_call("Target.getTargetInfo", json!({"targetId":target}), None, DOCUMENT_CHECK)?;
        let info = result.get("targetInfo").ok_or_else(|| self.refusal())?;
        let text = |key| info.get(key).and_then(Value::as_str).unwrap_or("");
        if text("targetId") != target || text("type") != "page" || !self.dialog_url(text("url"))
            || self.info_protected(info, &[]) { return Err(self.refusal()) }
        let url = text("url").to_string();
        self.targets.insert(target.to_string(), info.clone());
        Ok(url)
    }

    fn dialog_history(&mut self, session: &str) -> Result<String, String> {
        let history = self.raw_call("Page.getNavigationHistory", json!({}), Some(session), DOCUMENT_CHECK)?;
        let index = history.get("currentIndex").and_then(Value::as_u64)
            .and_then(|index| usize::try_from(index).ok()).ok_or_else(|| self.refusal())?;
        let url = history.get("entries").and_then(Value::as_array)
            .and_then(|entries| entries.get(index))
            .and_then(|entry| entry.get("url")).and_then(Value::as_str)
            .filter(|url| self.dialog_url(url)).ok_or_else(|| self.refusal())?;
        Ok(url.to_string())
    }

    fn check_dialog(&mut self, session: &str) -> Result<(), String> {
        if self.blocked.contains(session) || self.guard_sessions.contains(session) { return Err(self.refusal()) }
        let target = self.session_target(session).ok_or_else(|| self.refusal())?;
        let dialog = self.dialogs.get(session).cloned().ok_or(DIALOG_UNOBSERVED.to_string())?;
        if dialog.frame != target || !self.dialog_url(&dialog.url) { return Err(self.refusal()) }
        let url = self.dialog_target(&target)?;
        let current = self.dialog_history(session)?;
        if current != dialog.url || url != current || self.dialogs.get(session) != Some(&dialog)
            || self.blocked.contains(session) || self.protected_targets.contains(&target) { return Err(self.refusal()) }
        Ok(())
    }

    fn admit_dialog(&mut self, session: &str) -> Result<(), String> {
        self.check_dialog(session)?;
        self.dialogs.remove(session);
        Ok(())
    }

    fn observe_dialog(&mut self, event: &Value) {
        let method = event.get("method").and_then(Value::as_str).unwrap_or("");
        if method == "Target.detachedFromTarget" {
            if let Some(session) = event.pointer("/params/sessionId").and_then(Value::as_str) {
                self.dialog_sessions.remove(session);
                self.dialogs.remove(session);
                self.dialog_documents.remove(session);
            }
            return;
        }
        let Some(session) = event.get("sessionId").and_then(Value::as_str) else { return };
        match method {
            "Page.javascriptDialogOpening" if self.session_target(session).is_some() => {
                self.dialog_epoch += 1;
                self.dialogs.insert(session.to_string(), Dialog {
                    url: event.pointer("/params/url").and_then(Value::as_str).unwrap_or("").to_string(),
                    frame: event.pointer("/params/frameId").and_then(Value::as_str).unwrap_or("").to_string(),
                    epoch: self.dialog_epoch,
                });
            }
            "Page.frameNavigated" => {
                self.dialogs.remove(session);
                self.dialog_documents.remove(session);
                let frame = &event["params"]["frame"];
                let text = |key| frame.get(key).and_then(Value::as_str).unwrap_or("");
                let url = format!("{}{}", text("url"), text("urlFragment"));
                let origin = url::Url::parse(&url).ok().map(|url| url.origin().ascii_serialization());
                if self.session_target(session).as_deref() == Some(text("id")) && frame.get("parentId").is_none()
                    && self.dialog_url(&url) && text("unreachableUrl").is_empty() && frame_text_known(text("loaderId"))
                    && origin.as_deref() == Some(text("securityOrigin")) {
                    self.dialog_documents.insert(session.to_string(), DialogDocument {url, loader:text("loaderId").to_string()});
                }
            }
            "Page.frameStartedNavigating" => {
                self.dialogs.remove(session);
                self.dialog_documents.remove(session);
            }
            "Page.javascriptDialogClosed" => { self.dialogs.remove(session); }
            _ => {}
        }
    }
}