use std::{collections::HashSet, time::SystemTime};
use crate::{page::Page, protocol::types::*};
pub struct CdpSession {
pub enabled_domains: HashSet<String>,
pub scripts_on_new_document: Vec<String>,
pub frame_id: String,
loader_counter: u64,
request_counter: u64,
pub pending_navigate: Option<String>,
}
impl Default for CdpSession {
fn default() -> Self {
Self::new()
}
}
impl CdpSession {
pub fn new() -> Self {
Self {
enabled_domains: HashSet::new(),
scripts_on_new_document: Vec::new(),
frame_id: "main".to_string(),
loader_counter: 0,
request_counter: 0,
pending_navigate: None,
}
}
pub fn next_request_id(&mut self) -> String {
self.request_counter += 1;
format!("{}.1", self.request_counter)
}
pub fn next_loader_id(&mut self) -> String {
self.loader_counter += 1;
format!("loader-{}", self.loader_counter)
}
pub fn is_domain_enabled(&self, domain: &str) -> bool {
self.enabled_domains.contains(domain)
}
pub fn enable_domain(&mut self, domain: &str) {
self.enabled_domains.insert(domain.to_string());
}
pub async fn handle_request(
&mut self,
page: &mut Page,
req: &CdpRequest,
) -> (String, Vec<CdpEvent>) {
if req.method == "Runtime.evaluate" {
let expression = req
.params
.get("expression")
.and_then(|v| v.as_str())
.unwrap_or("");
return match page.evaluate(expression) {
Ok(result_str) => {
let ty = js_type(&result_str);
let return_by_value = req
.params
.get("returnByValue")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let resp_json = if return_by_value {
let raw_value = match ty {
"number" | "boolean" => result_str.clone(),
"undefined" => "null".to_string(),
_ => {
if serde_json::from_str::<serde_json::Value>(&result_str).is_ok() {
result_str.clone()
} else {
json_escape_string(&result_str)
}
}
};
format!(
r#"{{"id":{},"result":{{"result":{{"type":"{}","value":{}}}}}}}"#,
req.id, ty, raw_value
)
} else {
format!(
r#"{{"id":{},"result":{{"type":"{}","value":{}}}}}"#,
req.id,
ty,
json_escape_string(&result_str)
)
};
(resp_json, Vec::new())
}
Err(e) => {
let resp = to_json(&serde_json::json!({
"id": req.id,
"result": {
"exceptionDetails": {
"text": e.to_string(),
"lineNumber": 0,
"columnNumber": 0,
}
}
}));
(resp, Vec::new())
}
};
}
let mut events = Vec::new();
let result: Result<serde_json::Value, String> = match req.method.as_str() {
"Target.getTargets" => Ok(serde_json::json!({
"targetInfos": [{
"targetId": "page-1",
"type": "page",
"title": page.title(),
"url": page.url(),
"attached": true,
}]
})),
"Page.enable" => {
self.enable_domain("Page");
Ok(serde_json::json!({}))
}
"Page.navigate" => {
let url = req
.params
.get("url")
.and_then(|v| v.as_str())
.unwrap_or("about:blank");
let loader_id = self.next_loader_id();
if url != "about:blank" {
self.pending_navigate = Some(url.to_string());
}
if self.is_domain_enabled("Page") {
events.push(CdpEvent::new(
"Page.frameNavigated",
serde_json::json!({
"frame": {
"id": self.frame_id,
"loaderId": loader_id,
"url": url,
"securityOrigin": url,
"mimeType": "text/html",
}
}),
));
events.push(CdpEvent::new(
"Page.domContentEventFired",
serde_json::json!({ "timestamp": timestamp() }),
));
events.push(CdpEvent::new(
"Page.loadEventFired",
serde_json::json!({ "timestamp": timestamp() }),
));
}
Ok(serde_json::json!({
"frameId": self.frame_id,
"loaderId": loader_id,
}))
}
"Page.getFrameTree" => Ok(serde_json::json!({
"frameTree": {
"frame": {
"id": self.frame_id,
"loaderId": format!("loader-{}", self.loader_counter),
"url": page.url(),
"securityOrigin": page.url(),
"mimeType": "text/html",
},
"childFrames": []
}
})),
"Page.addScriptToEvaluateOnNewDocument" => {
let source = req
.params
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("");
self.scripts_on_new_document.push(source.to_string());
Ok(serde_json::json!({
"identifier": format!("script-{}", self.scripts_on_new_document.len())
}))
}
"Page.setLifecycleEventsEnabled" => Ok(serde_json::json!({})),
"Page.createIsolatedWorld" => Ok(serde_json::json!({ "executionContextId": 2 })),
"Runtime.enable" => {
self.enable_domain("Runtime");
events.push(CdpEvent::new(
"Runtime.executionContextCreated",
serde_json::json!({
"context": {
"id": 1,
"origin": page.url(),
"name": "",
"auxData": { "isDefault": true, "type": "default", "frameId": self.frame_id }
}
}),
));
Ok(serde_json::json!({}))
}
"Runtime.callFunctionOn" => {
let decl = req
.params
.get("functionDeclaration")
.and_then(|v| v.as_str())
.unwrap_or("() => undefined");
let code = format!("({})()", decl);
match page.evaluate(&code) {
Ok(result_str) => Ok(serde_json::json!({
"result": { "type": js_type(&result_str), "value": result_str }
})),
Err(e) => Ok(serde_json::json!({
"exceptionDetails": { "text": e.to_string() }
})),
}
}
"Runtime.disable" => Ok(serde_json::json!({})),
"Runtime.runIfWaitingForDebugger" => Ok(serde_json::json!({})),
"DOM.enable" => {
self.enable_domain("DOM");
Ok(serde_json::json!({}))
}
"DOM.getDocument" => {
let _depth = req
.params
.get("depth")
.and_then(|v| v.as_i64())
.unwrap_or(1);
Ok(serde_json::json!({
"root": {
"nodeId": 1,
"backendNodeId": 1,
"nodeType": 9,
"nodeName": "#document",
"localName": "",
"nodeValue": "",
"childNodeCount": 1,
"documentURL": page.url(),
"baseURL": page.url(),
}
}))
}
"DOM.querySelector" => {
let selector = req
.params
.get("selector")
.and_then(|v| v.as_str())
.unwrap_or("");
let has = page.has_element(selector);
Ok(serde_json::json!({ "nodeId": if has { 2 } else { 0 } }))
}
"DOM.getOuterHTML" => {
let result = page
.evaluate("document.documentElement.outerHTML")
.unwrap_or_default();
Ok(serde_json::json!({ "outerHTML": result }))
}
"DOM.disable" => Ok(serde_json::json!({})),
"Network.enable" => {
self.enable_domain("Network");
Ok(serde_json::json!({}))
}
"Network.getCookies" => Ok(serde_json::json!({ "cookies": [] })),
"Network.setCookies" => Ok(serde_json::json!({})),
"Network.disable" => {
self.enabled_domains.remove("Network");
Ok(serde_json::json!({}))
}
"Network.setExtraHTTPHeaders" => Ok(serde_json::json!({})),
"Network.getResponseBody" => Ok(serde_json::json!({
"body": "",
"base64Encoded": false,
})),
"Network.clearBrowserCache" => Ok(serde_json::json!({})),
"Network.clearBrowserCookies" => Ok(serde_json::json!({})),
"Network.setCacheDisabled" => Ok(serde_json::json!({})),
"Emulation.setDeviceMetricsOverride" => Ok(serde_json::json!({})),
"Emulation.setUserAgentOverride" => Ok(serde_json::json!({})),
"Emulation.setTouchEmulationEnabled" => Ok(serde_json::json!({})),
"Browser.getVersion" => Ok(serde_json::json!({
"protocolVersion": "1.3",
"product": "hpx-browser/0.1.0",
"revision": "0",
"userAgent": "Mozilla/5.0 hpx-browser/0.1.0",
"jsVersion": "V8",
})),
"Log.enable" => Ok(serde_json::json!({})),
"Log.disable" => Ok(serde_json::json!({})),
"Inspector.enable" => Ok(serde_json::json!({})),
"Performance.enable" => Ok(serde_json::json!({})),
"Security.enable" => Ok(serde_json::json!({})),
"Input.dispatchMouseEvent" => Ok(serde_json::json!({})),
"Input.dispatchKeyEvent" => Ok(serde_json::json!({})),
"Input.dispatchTouchEvent" => Ok(serde_json::json!({})),
"Input.insertText" => Ok(serde_json::json!({})),
"Input.setIgnoreInputEvents" => Ok(serde_json::json!({})),
_ => {
let err = CdpError::method_not_found(req.id, &req.method);
return (to_json(&err), events);
}
};
match result {
Ok(value) => (to_json(&CdpResponse::ok(req.id, value)), events),
Err(msg) => (to_json(&CdpError::internal(req.id, &msg)), events),
}
}
}
pub fn json_escape_string(s: &str) -> String {
if s.bytes().all(|b| b > 31 && b != b'"' && b != b'\\') {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
out.push_str(s);
out.push('"');
return out;
}
let mut out = String::with_capacity(s.len() + 8);
out.push('"');
for ch in s.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 32 => {
out.push_str(&format!("\\u{:04x}", c as u32));
}
c => out.push(c),
}
}
out.push('"');
out
}
fn timestamp() -> f64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
fn js_type(value: &str) -> &'static str {
match value {
"undefined" => "undefined",
"null" => "object",
"true" | "false" => "boolean",
s if s.parse::<f64>().is_ok() => "number",
_ => "string",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn handle_page_enable() {
let mut session = CdpSession::new();
let mut page = Page::from_html("<html><head></head><body></body></html>", None)
.await
.unwrap();
let req = CdpRequest {
id: 1,
method: "Page.enable".to_string(),
params: serde_json::Value::Null,
};
let (resp, _events) = session.handle_request(&mut page, &req).await;
assert!(resp.contains("\"id\":1"));
assert!(session.is_domain_enabled("Page"));
}
#[tokio::test]
async fn handle_runtime_evaluate() {
let mut session = CdpSession::new();
let mut page = Page::from_html("<html><head></head><body></body></html>", None)
.await
.unwrap();
let req = CdpRequest {
id: 2,
method: "Runtime.evaluate".to_string(),
params: serde_json::json!({"expression": "1 + 2"}),
};
let (resp, _) = session.handle_request(&mut page, &req).await;
assert!(resp.contains("\"id\":2"), "response: {}", resp);
}
#[tokio::test]
async fn handle_runtime_enable_emits_context() {
let mut session = CdpSession::new();
let mut page = Page::from_html("<html><head></head><body></body></html>", None)
.await
.unwrap();
let req = CdpRequest {
id: 3,
method: "Runtime.enable".to_string(),
params: serde_json::Value::Null,
};
let (_, events) = session.handle_request(&mut page, &req).await;
assert_eq!(events.len(), 1);
assert_eq!(events[0].method, "Runtime.executionContextCreated");
}
#[tokio::test]
async fn handle_page_navigate() {
let mut session = CdpSession::new();
session.enable_domain("Page");
let mut page = Page::from_html("<html><head></head><body></body></html>", None)
.await
.unwrap();
let req = CdpRequest {
id: 4,
method: "Page.navigate".to_string(),
params: serde_json::json!({"url": "about:blank"}),
};
let (resp, events) = session.handle_request(&mut page, &req).await;
assert!(resp.contains("frameId"));
assert!(resp.contains("loaderId"));
assert_eq!(events.len(), 3);
assert_eq!(events[0].method, "Page.frameNavigated");
assert_eq!(events[1].method, "Page.domContentEventFired");
assert_eq!(events[2].method, "Page.loadEventFired");
}
#[tokio::test]
async fn handle_dom_get_document() {
let mut session = CdpSession::new();
let mut page = Page::from_html("<html><head></head><body></body></html>", None)
.await
.unwrap();
let req = CdpRequest {
id: 5,
method: "DOM.getDocument".to_string(),
params: serde_json::json!({}),
};
let (resp, _) = session.handle_request(&mut page, &req).await;
assert!(resp.contains("\"nodeType\":9"));
assert!(resp.contains("#document"));
}
#[tokio::test]
async fn handle_unknown_method() {
let mut session = CdpSession::new();
let mut page = Page::from_html("<html><head></head><body></body></html>", None)
.await
.unwrap();
let req = CdpRequest {
id: 99,
method: "Unknown.method".to_string(),
params: serde_json::Value::Null,
};
let (resp, _) = session.handle_request(&mut page, &req).await;
assert!(resp.contains("-32601"), "response: {}", resp);
assert!(resp.contains("Unknown.method"));
}
#[tokio::test]
async fn handle_browser_get_version() {
let mut session = CdpSession::new();
let mut page = Page::from_html("<html><head></head><body></body></html>", None)
.await
.unwrap();
let req = CdpRequest {
id: 7,
method: "Browser.getVersion".to_string(),
params: serde_json::Value::Null,
};
let (resp, _) = session.handle_request(&mut page, &req).await;
assert!(resp.contains("hpx-browser"));
}
#[tokio::test]
async fn handle_add_script_on_new_document() {
let mut session = CdpSession::new();
let mut page = Page::from_html("<html><head></head><body></body></html>", None)
.await
.unwrap();
let req = CdpRequest {
id: 8,
method: "Page.addScriptToEvaluateOnNewDocument".to_string(),
params: serde_json::json!({"source": "window.__test = true;"}),
};
let (resp, _) = session.handle_request(&mut page, &req).await;
assert!(resp.contains("identifier"));
assert_eq!(session.scripts_on_new_document.len(), 1);
}
}