Initial Release
This commit is contained in:
1 parent
2e5d63cc35
commit
8749725b45
52 files changed
+19804
No files matched your search
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "obscura-cdp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
obscura-dom = { workspace = true }
|
||||
obscura-browser = { workspace = true }
|
||||
obscura-js = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
url = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
futures-util = "0.3"
|
||||
obscura-net = { workspace = true }
|
||||
@@ -0,0 +1,136 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use obscura_browser::{BrowserContext, Page};
|
||||
use obscura_js::ops::{InterceptResolution, InterceptedRequest};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::domains;
|
||||
use crate::domains::fetch::FetchInterceptState;
|
||||
use crate::types::{CdpEvent, CdpRequest, CdpResponse};
|
||||
|
||||
pub struct CdpContext {
|
||||
pub pages: Vec<Page>,
|
||||
pub sessions: HashMap<String, String>, // session_id -> page_id
|
||||
pub pending_events: Vec<CdpEvent>,
|
||||
pub default_context: Arc<BrowserContext>,
|
||||
page_counter: u32,
|
||||
pub preload_scripts: Vec<(String, String)>, // (identifier, source)
|
||||
pub preload_counter: u32,
|
||||
pub fetch_intercept: FetchInterceptState,
|
||||
pub intercept_tx: Option<tokio::sync::mpsc::UnboundedSender<InterceptedRequest>>,
|
||||
}
|
||||
|
||||
impl CdpContext {
|
||||
pub fn new() -> Self {
|
||||
Self::new_with_proxy(None)
|
||||
}
|
||||
|
||||
pub fn new_with_proxy(proxy: Option<String>) -> Self {
|
||||
let default_context = Arc::new(BrowserContext::with_proxy("default".to_string(), proxy));
|
||||
CdpContext {
|
||||
pages: Vec::new(),
|
||||
sessions: HashMap::new(),
|
||||
pending_events: Vec::new(),
|
||||
default_context,
|
||||
page_counter: 0,
|
||||
preload_scripts: Vec::new(),
|
||||
preload_counter: 0,
|
||||
fetch_intercept: FetchInterceptState::new(),
|
||||
intercept_tx: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_page(&mut self) -> String {
|
||||
self.page_counter += 1;
|
||||
let page_id = format!("page-{}", self.page_counter);
|
||||
let mut page = Page::new(page_id.clone(), self.default_context.clone());
|
||||
page.navigate_blank();
|
||||
self.pages.push(page);
|
||||
page_id
|
||||
}
|
||||
|
||||
pub fn get_page(&self, id: &str) -> Option<&Page> {
|
||||
self.pages.iter().find(|p| p.id == id)
|
||||
}
|
||||
|
||||
pub fn get_page_mut(&mut self, id: &str) -> Option<&mut Page> {
|
||||
self.pages.iter_mut().find(|p| p.id == id)
|
||||
}
|
||||
|
||||
pub fn remove_page(&mut self, id: &str) {
|
||||
self.pages.retain(|p| p.id != id);
|
||||
self.sessions.retain(|_, v| v != id);
|
||||
}
|
||||
|
||||
pub fn get_session_page(&self, session_id: &Option<String>) -> Option<&Page> {
|
||||
let page_id = session_id
|
||||
.as_ref()
|
||||
.and_then(|sid| self.sessions.get(sid))?;
|
||||
self.get_page(page_id)
|
||||
}
|
||||
|
||||
pub fn get_session_page_mut(&mut self, session_id: &Option<String>) -> Option<&mut Page> {
|
||||
let page_id = session_id
|
||||
.as_ref()
|
||||
.and_then(|sid| self.sessions.get(sid))
|
||||
.cloned()?;
|
||||
|
||||
let target_has_js = self.pages.iter().any(|p| p.id == page_id && p.has_js());
|
||||
|
||||
if !target_has_js {
|
||||
for page in &mut self.pages {
|
||||
if page.id != page_id && page.has_js() {
|
||||
page.suspend_js();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(target) = self.pages.iter_mut().find(|p| p.id == page_id) {
|
||||
target.resume_js();
|
||||
}
|
||||
}
|
||||
|
||||
self.get_page_mut(&page_id)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse {
|
||||
let (domain, method) = match req.method.split_once('.') {
|
||||
Some((d, m)) => (d, m),
|
||||
None => {
|
||||
return CdpResponse::error(
|
||||
req.id,
|
||||
-32601,
|
||||
format!("Invalid method format: {}", req.method),
|
||||
req.session_id.clone(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let result = match domain {
|
||||
"Target" => domains::target::handle(method, &req.params, ctx).await,
|
||||
"Browser" => domains::browser::handle(method, &req.params).await,
|
||||
"Page" => domains::page::handle(method, &req.params, ctx, &req.session_id).await,
|
||||
"DOM" => domains::dom::handle(method, &req.params, ctx, &req.session_id).await,
|
||||
"Runtime" => domains::runtime::handle(method, &req.params, ctx, &req.session_id).await,
|
||||
"Network" => domains::network::handle(method, &req.params, ctx, &req.session_id).await,
|
||||
"Fetch" => domains::fetch::handle(method, &req.params, ctx, &req.session_id).await,
|
||||
"Input" => domains::input::handle(method, &req.params, ctx, &req.session_id).await,
|
||||
"Storage" => domains::storage::handle(method, &req.params, ctx, &req.session_id).await,
|
||||
"LP" => domains::lp::handle(method, &req.params, ctx, &req.session_id).await,
|
||||
"Emulation" | "Log" | "Performance" | "Security" | "CSS"
|
||||
| "Accessibility" | "ServiceWorker" | "Inspector"
|
||||
| "Debugger" | "Profiler" | "HeapProfiler" | "Overlay" => {
|
||||
Ok(json!({}))
|
||||
}
|
||||
_ => Err(format!("Unknown domain: {}", domain)),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(value) => CdpResponse::success(req.id, value, req.session_id.clone()),
|
||||
Err(msg) => {
|
||||
tracing::warn!("CDP error for {}: {}", req.method, msg);
|
||||
CdpResponse::error(req.id, -32601, msg, req.session_id.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn handle(method: &str, _params: &Value) -> Result<Value, String> {
|
||||
match method {
|
||||
"getVersion" => Ok(json!({
|
||||
"protocolVersion": "1.3",
|
||||
"product": "Obscura/0.1.0",
|
||||
"revision": "0",
|
||||
"userAgent": "Obscura/0.1.0 (Headless Browser)",
|
||||
"jsVersion": "N/A",
|
||||
})),
|
||||
"close" => {
|
||||
Ok(json!({}))
|
||||
}
|
||||
"getWindowForTarget" => Ok(json!({
|
||||
"windowId": 1,
|
||||
"bounds": {
|
||||
"left": 0,
|
||||
"top": 0,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"windowState": "normal",
|
||||
}
|
||||
})),
|
||||
"setDownloadBehavior" => Ok(json!({})),
|
||||
"getWindowBounds" => Ok(json!({
|
||||
"bounds": { "left": 0, "top": 0, "width": 1280, "height": 720, "windowState": "normal" }
|
||||
})),
|
||||
_ => Err(format!("Unknown Browser method: {}", method)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
use obscura_dom::{DomTree, NodeData, NodeId};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::dispatch::CdpContext;
|
||||
|
||||
pub async fn handle(
|
||||
method: &str,
|
||||
params: &Value,
|
||||
ctx: &mut CdpContext,
|
||||
session_id: &Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
match method {
|
||||
"enable" => Ok(json!({})),
|
||||
"getDocument" => {
|
||||
let page = ctx.get_session_page(session_id).ok_or("No page")?;
|
||||
let depth = params.get("depth").and_then(|v| v.as_i64()).unwrap_or(2);
|
||||
page.with_dom(|dom| {
|
||||
let node = serialize_node(dom, dom.document(), depth as u32, 0);
|
||||
json!({ "root": node })
|
||||
}).ok_or_else(|| "No DOM loaded".to_string())
|
||||
}
|
||||
"querySelector" => {
|
||||
let page = ctx.get_session_page(session_id).ok_or("No page")?;
|
||||
let selector = params.get("selector").and_then(|v| v.as_str()).ok_or("selector required")?;
|
||||
let result = page.with_dom(|dom| {
|
||||
dom.query_selector(selector).ok().flatten().map(|id| id.index()).unwrap_or(0)
|
||||
}).unwrap_or(0);
|
||||
Ok(json!({ "nodeId": result }))
|
||||
}
|
||||
"querySelectorAll" => {
|
||||
let page = ctx.get_session_page(session_id).ok_or("No page")?;
|
||||
let selector = params.get("selector").and_then(|v| v.as_str()).ok_or("selector required")?;
|
||||
let ids = page.with_dom(|dom| {
|
||||
dom.query_selector_all(selector).ok()
|
||||
.map(|ids| ids.iter().map(|id| id.index() as u64).collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
}).unwrap_or_default();
|
||||
Ok(json!({ "nodeIds": ids }))
|
||||
}
|
||||
"getOuterHTML" => {
|
||||
let page = ctx.get_session_page(session_id).ok_or("No page")?;
|
||||
let node_id = params.get("nodeId").and_then(|v| v.as_u64())
|
||||
.or_else(|| params.get("backendNodeId").and_then(|v| v.as_u64()))
|
||||
.ok_or("nodeId required")?;
|
||||
let html = page.with_dom(|dom| {
|
||||
dom.outer_html(NodeId::new(node_id as u32))
|
||||
}).unwrap_or_default();
|
||||
Ok(json!({ "outerHTML": html }))
|
||||
}
|
||||
"describeNode" => {
|
||||
let page = ctx.get_session_page_mut(session_id).ok_or("No page")?;
|
||||
let depth = params.get("depth").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
|
||||
let node_id = if let Some(nid) = params.get("nodeId").and_then(|v| v.as_u64())
|
||||
.or_else(|| params.get("backendNodeId").and_then(|v| v.as_u64()))
|
||||
{
|
||||
nid
|
||||
} else if let Some(oid) = params.get("objectId").and_then(|v| v.as_str()) {
|
||||
let escaped_oid = oid.replace('\\', "\\\\").replace('\'', "\\'");
|
||||
let code = format!(
|
||||
"(function() {{ var o = globalThis.__obscura_objects['{}']; if (!o) return -1; return (typeof o._nid === 'number') ? o._nid : -1; }})()",
|
||||
escaped_oid
|
||||
);
|
||||
let result = page.evaluate(&code);
|
||||
result.as_f64().map(|n| n as u64).unwrap_or(0)
|
||||
} else {
|
||||
return Err("nodeId or objectId required".to_string());
|
||||
};
|
||||
|
||||
let node = page.with_dom(|dom| {
|
||||
serialize_node(dom, NodeId::new(node_id as u32), depth as u32, 0)
|
||||
}).unwrap_or(json!(null));
|
||||
Ok(json!({ "node": node }))
|
||||
}
|
||||
"resolveNode" => {
|
||||
let page = ctx.get_session_page_mut(session_id).ok_or("No page")?;
|
||||
let node_id = if let Some(nid) = params.get("nodeId").and_then(|v| v.as_u64())
|
||||
.or_else(|| params.get("backendNodeId").and_then(|v| v.as_u64()))
|
||||
{
|
||||
nid
|
||||
} else if let Some(oid) = params.get("objectId").and_then(|v| v.as_str()) {
|
||||
let code = format!(
|
||||
"(function() {{ var o = globalThis.__obscura_objects['{}']; return (o && typeof o._nid === 'number') ? o._nid : -1; }})()",
|
||||
oid
|
||||
);
|
||||
let result = page.evaluate(&code);
|
||||
result.as_f64().map(|n| n as u64).unwrap_or(0)
|
||||
} else {
|
||||
return Err("nodeId or objectId required".to_string());
|
||||
};
|
||||
|
||||
let js_code = format!(
|
||||
"(function() {{\
|
||||
var nid = {};\
|
||||
var node = null;\
|
||||
if (globalThis._cache && globalThis._cache.has(nid)) {{\
|
||||
node = globalThis._cache.get(nid);\
|
||||
}} else {{\
|
||||
var t = +Deno.core.ops.op_dom('node_type', String(nid), '');\
|
||||
if (t === 1) node = new Element(nid);\
|
||||
else if (t === 9) node = globalThis.document;\
|
||||
else node = new Node(nid);\
|
||||
if (globalThis._cache) globalThis._cache.set(nid, node);\
|
||||
}}\
|
||||
return node;\
|
||||
}})()",
|
||||
node_id,
|
||||
);
|
||||
|
||||
let info = if let Some(js) = &mut page.js {
|
||||
match js.store_object_with_meta(&js_code) {
|
||||
Ok(info) => info,
|
||||
Err(_) => {
|
||||
return Ok(json!({
|
||||
"object": {
|
||||
"type": "object",
|
||||
"subtype": "node",
|
||||
"className": "HTMLElement",
|
||||
"objectId": format!("node-{}", node_id),
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err("No JS runtime".to_string());
|
||||
};
|
||||
|
||||
Ok(json!({
|
||||
"object": {
|
||||
"type": "object",
|
||||
"subtype": "node",
|
||||
"className": if info.class_name.is_empty() { "HTMLElement".to_string() } else { info.class_name },
|
||||
"description": info.description,
|
||||
"objectId": info.object_id.unwrap_or_else(|| format!("node-{}", node_id)),
|
||||
}
|
||||
}))
|
||||
}
|
||||
"setAttributeValue" => Ok(json!({})),
|
||||
"removeNode" => Ok(json!({})),
|
||||
"getBoxModel" => {
|
||||
Ok(json!({
|
||||
"model": {
|
||||
"content": [8,8, 108,8, 108,28, 8,28],
|
||||
"padding": [8,8, 108,8, 108,28, 8,28],
|
||||
"border": [8,8, 108,8, 108,28, 8,28],
|
||||
"margin": [0,0, 116,0, 116,36, 0,36],
|
||||
"width": 100, "height": 20,
|
||||
}
|
||||
}))
|
||||
}
|
||||
_ => Err(format!("Unknown DOM method: {}", method)),
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_node(dom: &DomTree, node_id: NodeId, max_depth: u32, current_depth: u32) -> Value {
|
||||
let node = match dom.get_node(node_id) {
|
||||
Some(n) => n,
|
||||
None => return json!(null),
|
||||
};
|
||||
let children_ids = dom.children(node_id);
|
||||
let child_count = children_ids.len();
|
||||
let mut result = json!({ "nodeId": node_id.index(), "backendNodeId": node_id.index(), "childNodeCount": child_count });
|
||||
|
||||
match &node.data {
|
||||
NodeData::Document => {
|
||||
result["nodeType"] = json!(9); result["nodeName"] = json!("#document");
|
||||
result["localName"] = json!(""); result["nodeValue"] = json!("");
|
||||
result["documentURL"] = json!(""); result["baseURL"] = json!(""); result["xmlVersion"] = json!("");
|
||||
}
|
||||
NodeData::Doctype { name, public_id, system_id } => {
|
||||
result["nodeType"] = json!(10); result["nodeName"] = json!(name);
|
||||
result["localName"] = json!(""); result["nodeValue"] = json!("");
|
||||
result["publicId"] = json!(public_id); result["systemId"] = json!(system_id);
|
||||
}
|
||||
NodeData::Element { name, attrs, .. } => {
|
||||
result["nodeType"] = json!(1);
|
||||
result["nodeName"] = json!(name.local.as_ref().to_ascii_uppercase());
|
||||
result["localName"] = json!(name.local.as_ref());
|
||||
result["nodeValue"] = json!("");
|
||||
let cdp_attrs: Vec<String> = attrs.iter()
|
||||
.flat_map(|a| vec![a.name.local.to_string(), a.value.clone()]).collect();
|
||||
result["attributes"] = json!(cdp_attrs);
|
||||
}
|
||||
NodeData::Text { contents } => {
|
||||
result["nodeType"] = json!(3); result["nodeName"] = json!("#text");
|
||||
result["localName"] = json!(""); result["nodeValue"] = json!(contents);
|
||||
}
|
||||
NodeData::Comment { contents } => {
|
||||
result["nodeType"] = json!(8); result["nodeName"] = json!("#comment");
|
||||
result["localName"] = json!(""); result["nodeValue"] = json!(contents);
|
||||
}
|
||||
NodeData::ProcessingInstruction { target, data } => {
|
||||
result["nodeType"] = json!(7); result["nodeName"] = json!(target);
|
||||
result["localName"] = json!(""); result["nodeValue"] = json!(data);
|
||||
}
|
||||
}
|
||||
|
||||
if current_depth < max_depth && !children_ids.is_empty() {
|
||||
let children: Vec<Value> = children_ids.iter()
|
||||
.map(|&cid| serialize_node(dom, cid, max_depth, current_depth + 1)).collect();
|
||||
result["children"] = json!(children);
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::dispatch::CdpContext;
|
||||
use crate::types::CdpEvent;
|
||||
|
||||
pub struct PausedRequest {
|
||||
pub request_id: String,
|
||||
pub url: String,
|
||||
pub method: String,
|
||||
pub headers: HashMap<String, String>,
|
||||
pub resource_type: String,
|
||||
pub resolver: tokio::sync::oneshot::Sender<FetchResolution>,
|
||||
}
|
||||
|
||||
pub enum FetchResolution {
|
||||
Continue {
|
||||
url: Option<String>,
|
||||
method: Option<String>,
|
||||
headers: Option<HashMap<String, String>>,
|
||||
post_data: Option<String>,
|
||||
},
|
||||
Fulfill {
|
||||
status: u16,
|
||||
headers: Vec<(String, String)>,
|
||||
body: String,
|
||||
},
|
||||
Fail {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct FetchInterceptState {
|
||||
pub enabled: bool,
|
||||
pub patterns: Vec<String>,
|
||||
pub paused: HashMap<String, PausedRequest>,
|
||||
request_counter: u64,
|
||||
}
|
||||
|
||||
impl FetchInterceptState {
|
||||
pub fn new() -> Self {
|
||||
FetchInterceptState {
|
||||
enabled: false,
|
||||
patterns: Vec::new(),
|
||||
paused: HashMap::new(),
|
||||
request_counter: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_request_id(&mut self) -> String {
|
||||
self.request_counter += 1;
|
||||
format!("interception-{}", self.request_counter)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle(
|
||||
method: &str,
|
||||
params: &Value,
|
||||
ctx: &mut CdpContext,
|
||||
session_id: &Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
match method {
|
||||
"enable" => {
|
||||
let patterns = params
|
||||
.get("patterns")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|p| {
|
||||
p.get("urlPattern")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_else(|| vec!["*".to_string()]);
|
||||
|
||||
ctx.fetch_intercept.enabled = true;
|
||||
ctx.fetch_intercept.patterns = patterns.clone();
|
||||
let tx_clone = ctx.intercept_tx.clone();
|
||||
if let Some(page) = ctx.get_session_page_mut(session_id) {
|
||||
page.intercept_enabled = false;
|
||||
page.intercept_block_patterns.clear();
|
||||
if let Some(tx) = tx_clone {
|
||||
page.set_intercept_tx(tx);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Fetch interception enabled");
|
||||
Ok(json!({}))
|
||||
}
|
||||
"disable" => {
|
||||
ctx.fetch_intercept.enabled = false;
|
||||
ctx.fetch_intercept.patterns.clear();
|
||||
if let Some(page) = ctx.get_session_page_mut(session_id) {
|
||||
page.intercept_enabled = false;
|
||||
page.intercept_block_patterns.clear();
|
||||
}
|
||||
let paused: Vec<_> = ctx.fetch_intercept.paused.drain().collect();
|
||||
for (_, req) in paused {
|
||||
let _ = req.resolver.send(FetchResolution::Continue {
|
||||
url: None,
|
||||
method: None,
|
||||
headers: None,
|
||||
post_data: None,
|
||||
});
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"continueRequest" => {
|
||||
let request_id = params
|
||||
.get("requestId")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("requestId required")?;
|
||||
|
||||
if let Some(paused) = ctx.fetch_intercept.paused.remove(request_id) {
|
||||
let _ = paused.resolver.send(FetchResolution::Continue {
|
||||
url: params.get("url").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
method: params.get("method").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
headers: None,
|
||||
post_data: params.get("postData").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
});
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"fulfillRequest" => {
|
||||
let request_id = params
|
||||
.get("requestId")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("requestId required")?;
|
||||
|
||||
let status = params
|
||||
.get("responseCode")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(200) as u16;
|
||||
let headers: HashMap<String, String> = params
|
||||
.get("responseHeaders")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|h| {
|
||||
let name = h.get("name")?.as_str()?.to_string();
|
||||
let value = h.get("value")?.as_str()?.to_string();
|
||||
Some((name, value))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let body = params
|
||||
.get("body")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
if let Some(paused) = ctx.fetch_intercept.paused.remove(request_id) {
|
||||
let _ = paused.resolver.send(FetchResolution::Fulfill {
|
||||
status,
|
||||
headers: headers.into_iter().collect(),
|
||||
body,
|
||||
});
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"failRequest" => {
|
||||
let request_id = params
|
||||
.get("requestId")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("requestId required")?;
|
||||
|
||||
let reason = params
|
||||
.get("errorReason")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Failed")
|
||||
.to_string();
|
||||
|
||||
if let Some(paused) = ctx.fetch_intercept.paused.remove(request_id) {
|
||||
let _ = paused.resolver.send(FetchResolution::Fail { reason });
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"getResponseBody" => {
|
||||
Ok(json!({ "body": "", "base64Encoded": false }))
|
||||
}
|
||||
_ => Err(format!("Unknown Fetch method: {}", method)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::dispatch::CdpContext;
|
||||
|
||||
pub async fn handle(
|
||||
method: &str,
|
||||
params: &Value,
|
||||
ctx: &mut CdpContext,
|
||||
session_id: &Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
match method {
|
||||
"dispatchMouseEvent" => {
|
||||
let event_type = params.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let x = params.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let y = params.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let button = params.get("button").and_then(|v| v.as_str()).unwrap_or("left");
|
||||
let click_count = params.get("clickCount").and_then(|v| v.as_u64()).unwrap_or(1);
|
||||
|
||||
if event_type == "mousePressed" {
|
||||
if let Some(page) = ctx.get_session_page_mut(session_id) {
|
||||
let code = format!(
|
||||
"(function() {{\
|
||||
var target = globalThis.__obscura_click_target || document.activeElement || document.body;\
|
||||
if (!target) return;\
|
||||
var evt = new MouseEvent('mousedown', {{bubbles:true,cancelable:true,clientX:{x},clientY:{y},button:0}});\
|
||||
target.dispatchEvent(evt);\
|
||||
var click = new MouseEvent('click', {{bubbles:true,cancelable:true,clientX:{x},clientY:{y},button:0}});\
|
||||
var cancelled = !target.dispatchEvent(click);\
|
||||
if (!cancelled) {{\
|
||||
var link = target.closest ? target.closest('a[href]') : null;\
|
||||
if (!link && target.tagName === 'A' && target.getAttribute('href')) link = target;\
|
||||
if (link) {{\
|
||||
var href = link.getAttribute('href');\
|
||||
if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {{\
|
||||
location.assign(href);\
|
||||
}}\
|
||||
}}\
|
||||
}}\
|
||||
}})()",
|
||||
x = x, y = y,
|
||||
);
|
||||
page.evaluate(&code);
|
||||
}
|
||||
} else if event_type == "mouseReleased" {
|
||||
if let Some(page) = ctx.get_session_page_mut(session_id) {
|
||||
let code = format!(
|
||||
"(function() {{\
|
||||
var target = globalThis.__obscura_click_target || document.activeElement || document.body;\
|
||||
if (!target) return;\
|
||||
var evt = new MouseEvent('mouseup', {{bubbles:true,cancelable:true,clientX:{x},clientY:{y},button:0}});\
|
||||
target.dispatchEvent(evt);\
|
||||
}})()",
|
||||
x = x, y = y,
|
||||
);
|
||||
page.evaluate(&code);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({}))
|
||||
}
|
||||
"dispatchKeyEvent" => {
|
||||
let event_type = params.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let key = params.get("key").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let code = params.get("code").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let text = params.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
if let Some(page) = ctx.get_session_page_mut(session_id) {
|
||||
match event_type {
|
||||
"keyDown" | "rawKeyDown" => {
|
||||
let js = format!(
|
||||
"(function() {{\
|
||||
var target = document.activeElement || document.body;\
|
||||
var evt = new KeyboardEvent('keydown', {{bubbles:true,cancelable:true,key:'{key}',code:'{code}'}});\
|
||||
target.dispatchEvent(evt);\
|
||||
}})()",
|
||||
key = key.replace('\'', "\\'"),
|
||||
code = code.replace('\'', "\\'"),
|
||||
);
|
||||
page.evaluate(&js);
|
||||
|
||||
if !text.is_empty() && text != "\r" && text != "\n" {
|
||||
let js = format!(
|
||||
"(function() {{\
|
||||
var target = document.activeElement;\
|
||||
if (target && (target.localName === 'input' || target.localName === 'textarea')) {{\
|
||||
target.value = (target.value || '') + '{text}';\
|
||||
target.dispatchEvent(new Event('input', {{bubbles:true}}));\
|
||||
}}\
|
||||
}})()",
|
||||
text = text.replace('\'', "\\'").replace('\\', "\\\\"),
|
||||
);
|
||||
page.evaluate(&js);
|
||||
}
|
||||
|
||||
if key == "Enter" {
|
||||
let js = "(function() {\
|
||||
var target = document.activeElement;\
|
||||
if (target) {\
|
||||
target.dispatchEvent(new KeyboardEvent('keypress', {bubbles:true,key:'Enter',code:'Enter'}));\
|
||||
var form = target.form || target.closest && target.closest('form');\
|
||||
if (form && typeof form.submit === 'function') form.submit();\
|
||||
}\
|
||||
})()";
|
||||
page.evaluate(js);
|
||||
}
|
||||
|
||||
if key == "Backspace" {
|
||||
let js = "(function() {\
|
||||
var target = document.activeElement;\
|
||||
if (target && (target.localName === 'input' || target.localName === 'textarea')) {\
|
||||
target.value = target.value.slice(0, -1);\
|
||||
target.dispatchEvent(new Event('input', {bubbles:true}));\
|
||||
}\
|
||||
})()";
|
||||
page.evaluate(js);
|
||||
}
|
||||
}
|
||||
"keyUp" => {
|
||||
let js = format!(
|
||||
"(function() {{\
|
||||
var target = document.activeElement || document.body;\
|
||||
var evt = new KeyboardEvent('keyup', {{bubbles:true,key:'{key}',code:'{code}'}});\
|
||||
target.dispatchEvent(evt);\
|
||||
}})()",
|
||||
key = key.replace('\'', "\\'"),
|
||||
code = code.replace('\'', "\\'"),
|
||||
);
|
||||
page.evaluate(&js);
|
||||
}
|
||||
"char" => {
|
||||
if !text.is_empty() {
|
||||
let js = format!(
|
||||
"(function() {{\
|
||||
var target = document.activeElement;\
|
||||
if (target && (target.localName === 'input' || target.localName === 'textarea')) {{\
|
||||
target.value = (target.value || '') + '{text}';\
|
||||
target.dispatchEvent(new Event('input', {{bubbles:true}}));\
|
||||
}}\
|
||||
}})()",
|
||||
text = text.replace('\'', "\\'").replace('\\', "\\\\"),
|
||||
);
|
||||
page.evaluate(&js);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({}))
|
||||
}
|
||||
"dispatchTouchEvent" => Ok(json!({})),
|
||||
"setIgnoreInputEvents" => Ok(json!({})),
|
||||
_ => Err(format!("Unknown Input method: {}", method)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::dispatch::CdpContext;
|
||||
|
||||
pub async fn handle(
|
||||
method: &str,
|
||||
_params: &Value,
|
||||
ctx: &mut CdpContext,
|
||||
session_id: &Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
match method {
|
||||
"getMarkdown" => {
|
||||
let page = ctx.get_session_page_mut(session_id).ok_or("No page")?;
|
||||
let code = r#"
|
||||
(function() {
|
||||
function toMd(el, depth) {
|
||||
if (!el) return '';
|
||||
var out = '';
|
||||
if (el.nodeType === 3) return el.textContent || '';
|
||||
if (el.nodeType !== 1) return '';
|
||||
var tag = (el.tagName || '').toLowerCase();
|
||||
var children = '';
|
||||
var cn = el.childNodes || [];
|
||||
for (var i = 0; i < cn.length; i++) children += toMd(cn[i], depth);
|
||||
children = children.replace(/\n{3,}/g, '\n\n');
|
||||
switch(tag) {
|
||||
case 'h1': return '\n# ' + children.trim() + '\n\n';
|
||||
case 'h2': return '\n## ' + children.trim() + '\n\n';
|
||||
case 'h3': return '\n### ' + children.trim() + '\n\n';
|
||||
case 'h4': return '\n#### ' + children.trim() + '\n\n';
|
||||
case 'h5': return '\n##### ' + children.trim() + '\n\n';
|
||||
case 'h6': return '\n###### ' + children.trim() + '\n\n';
|
||||
case 'p': return '\n' + children.trim() + '\n\n';
|
||||
case 'br': return '\n';
|
||||
case 'hr': return '\n---\n\n';
|
||||
case 'strong': case 'b': return '**' + children + '**';
|
||||
case 'em': case 'i': return '*' + children + '*';
|
||||
case 'code': return '`' + children + '`';
|
||||
case 'pre': return '\n```\n' + children + '\n```\n\n';
|
||||
case 'blockquote': return '\n> ' + children.trim().replace(/\n/g, '\n> ') + '\n\n';
|
||||
case 'a':
|
||||
var href = el.getAttribute('href') || '';
|
||||
if (href && children.trim()) return '[' + children.trim() + '](' + href + ')';
|
||||
return children;
|
||||
case 'img':
|
||||
var src = el.getAttribute('src') || '';
|
||||
var alt = el.getAttribute('alt') || '';
|
||||
return '';
|
||||
case 'ul': case 'ol':
|
||||
return '\n' + children + '\n';
|
||||
case 'li':
|
||||
var parent = el.parentNode;
|
||||
var isOrdered = parent && parent.tagName && parent.tagName.toLowerCase() === 'ol';
|
||||
var bullet = isOrdered ? '1. ' : '- ';
|
||||
return bullet + children.trim() + '\n';
|
||||
case 'table': return '\n' + children + '\n';
|
||||
case 'thead': case 'tbody': case 'tfoot': return children;
|
||||
case 'tr':
|
||||
var cells = [];
|
||||
var tds = el.childNodes || [];
|
||||
for (var j = 0; j < tds.length; j++) {
|
||||
if (tds[j].nodeType === 1) cells.push(toMd(tds[j], depth).trim());
|
||||
}
|
||||
return '| ' + cells.join(' | ') + ' |\n';
|
||||
case 'th': case 'td': return children;
|
||||
case 'script': case 'style': case 'noscript': case 'link': case 'meta': return '';
|
||||
case 'div': case 'section': case 'article': case 'main': case 'aside': case 'nav': case 'header': case 'footer':
|
||||
return '\n' + children;
|
||||
case 'span': return children;
|
||||
default: return children;
|
||||
}
|
||||
}
|
||||
var body = document.body || document.documentElement;
|
||||
var md = toMd(body, 0);
|
||||
md = md.replace(/\n{3,}/g, '\n\n').trim();
|
||||
return md;
|
||||
})()
|
||||
"#;
|
||||
let result = page.evaluate(code);
|
||||
let markdown = result.as_str().unwrap_or("").to_string();
|
||||
Ok(json!({ "markdown": markdown }))
|
||||
}
|
||||
_ => Err(format!("Unknown LP method: {}", method)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
pub mod target;
|
||||
pub mod browser;
|
||||
pub mod page;
|
||||
pub mod dom;
|
||||
pub mod runtime;
|
||||
pub mod network;
|
||||
pub mod fetch;
|
||||
pub mod input;
|
||||
pub mod storage;
|
||||
pub mod lp;
|
||||
@@ -0,0 +1,81 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::dispatch::CdpContext;
|
||||
|
||||
pub async fn handle(
|
||||
method: &str,
|
||||
params: &Value,
|
||||
ctx: &mut CdpContext,
|
||||
session_id: &Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
match method {
|
||||
"enable" => Ok(json!({})),
|
||||
"setExtraHTTPHeaders" => {
|
||||
let headers = params.get("headers").and_then(|v| v.as_object());
|
||||
if let Some(page) = ctx.get_session_page(session_id) {
|
||||
if let Some(headers) = headers {
|
||||
let header_map: std::collections::HashMap<String, String> = headers
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
|
||||
.collect();
|
||||
page.http_client.set_extra_headers(header_map).await;
|
||||
}
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"setUserAgentOverride" => {
|
||||
let ua = params.get("userAgent").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if let Some(page) = ctx.get_session_page(session_id) {
|
||||
page.http_client.set_user_agent(ua).await;
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"getCookies" => {
|
||||
let page = ctx.get_session_page(session_id).ok_or("No page")?;
|
||||
let cookies = page.context.cookie_jar.get_all_cookies();
|
||||
let cdp_cookies: Vec<Value> = cookies.iter().map(|c| {
|
||||
json!({
|
||||
"name": c.name,
|
||||
"value": c.value,
|
||||
"domain": c.domain,
|
||||
"path": c.path,
|
||||
"expires": -1,
|
||||
"size": c.name.len() + c.value.len(),
|
||||
"httpOnly": c.http_only,
|
||||
"secure": c.secure,
|
||||
"session": true,
|
||||
"sameParty": false,
|
||||
"sourceScheme": "Secure",
|
||||
"sourcePort": 443,
|
||||
})
|
||||
}).collect();
|
||||
Ok(json!({ "cookies": cdp_cookies }))
|
||||
}
|
||||
"setCookies" => {
|
||||
let page = ctx.get_session_page(session_id).ok_or("No page")?;
|
||||
if let Some(cookies) = params.get("cookies").and_then(|v| v.as_array()) {
|
||||
let cookie_infos: Vec<obscura_net::CookieInfo> = cookies.iter().filter_map(|c| {
|
||||
Some(obscura_net::CookieInfo {
|
||||
name: c.get("name")?.as_str()?.to_string(),
|
||||
value: c.get("value")?.as_str()?.to_string(),
|
||||
domain: c.get("domain").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
path: c.get("path").and_then(|v| v.as_str()).unwrap_or("/").to_string(),
|
||||
secure: c.get("secure").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
http_only: c.get("httpOnly").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
})
|
||||
}).collect();
|
||||
page.context.cookie_jar.set_cookies_from_cdp(cookie_infos);
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"clearBrowserCookies" => {
|
||||
if let Some(page) = ctx.get_session_page(session_id) {
|
||||
page.context.cookie_jar.clear();
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"setCacheDisabled" => Ok(json!({})),
|
||||
"setRequestInterception" => Ok(json!({})),
|
||||
_ => Err(format!("Unknown Network method: {}", method)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
use obscura_browser::lifecycle::WaitUntil;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::dispatch::CdpContext;
|
||||
use crate::types::CdpEvent;
|
||||
|
||||
pub async fn handle(
|
||||
method: &str,
|
||||
params: &Value,
|
||||
ctx: &mut CdpContext,
|
||||
session_id: &Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
match method {
|
||||
"enable" => Ok(json!({})),
|
||||
"navigate" => {
|
||||
let url = params.get("url").and_then(|v| v.as_str())
|
||||
.ok_or("url required")?;
|
||||
|
||||
let wait_until = params.get("waitUntil")
|
||||
.and_then(|v| {
|
||||
if let Some(s) = v.as_str() {
|
||||
Some(WaitUntil::from_str(s))
|
||||
} else if let Some(arr) = v.as_array() {
|
||||
arr.iter()
|
||||
.filter_map(|item| item.as_str())
|
||||
.map(WaitUntil::from_str)
|
||||
.max_by_key(|w| match w {
|
||||
WaitUntil::DomContentLoaded => 0,
|
||||
WaitUntil::Load => 1,
|
||||
WaitUntil::NetworkIdle2 => 2,
|
||||
WaitUntil::NetworkIdle0 => 3,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(WaitUntil::Load);
|
||||
|
||||
let preload_scripts: Vec<String> = ctx.preload_scripts.iter().map(|(_, s)| s.clone()).collect();
|
||||
|
||||
let (frame_id, loader_id, network_events, page_url, page_id, reached_network_idle) = {
|
||||
let page = ctx.get_session_page_mut(session_id).ok_or("No page for session")?;
|
||||
let frame_id = page.frame_id.clone();
|
||||
let loader_id = format!("loader-{}", uuid::Uuid::new_v4());
|
||||
|
||||
let nav_method = params.get("__method").and_then(|v| v.as_str()).unwrap_or("GET");
|
||||
let nav_body = params.get("__body").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if nav_method == "POST" && !nav_body.is_empty() {
|
||||
page.navigate_with_wait_post(url, wait_until, nav_method, nav_body).await.map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
page.navigate_with_wait(url, wait_until).await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
for source in &preload_scripts {
|
||||
if let Err(e) = page.execute_preload_script(source) {
|
||||
tracing::debug!("Preload script error: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let reached_network_idle = page.lifecycle.is_network_idle();
|
||||
let network_events: Vec<_> = page.network_events.drain(..).collect();
|
||||
let page_url = page.url_string();
|
||||
let page_id = page.id.clone();
|
||||
(frame_id, loader_id, network_events, page_url, page_id, reached_network_idle)
|
||||
};
|
||||
|
||||
let es = session_id.clone();
|
||||
let ts = timestamp();
|
||||
|
||||
let phase1 = vec![
|
||||
CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "init", "timestamp": ts}), session_id: es.clone() },
|
||||
CdpEvent { method: "Runtime.executionContextsCleared".into(), params: json!({}), session_id: es.clone() },
|
||||
CdpEvent { method: "Page.frameNavigated".into(), params: json!({"frame": {"id": frame_id, "loaderId": loader_id, "url": page_url, "domainAndRegistry": "", "securityOrigin": page_url, "mimeType": "text/html", "adFrameStatus": {"adFrameType": "none"}}, "type": "Navigation"}), session_id: es.clone() },
|
||||
CdpEvent { method: "Runtime.executionContextCreated".into(), params: json!({"context": {"id": 2, "origin": page_url, "name": "", "uniqueId": format!("ctx-nav-{}", page_id), "auxData": {"isDefault": true, "type": "default", "frameId": frame_id}}}), session_id: es.clone() },
|
||||
CdpEvent { method: "Runtime.executionContextCreated".into(), params: json!({"context": {"id": 100, "origin": page_url, "name": "__puppeteer_utility_world__24.40.0", "uniqueId": format!("ctx-isolated-nav-{}", page_id), "auxData": {"isDefault": false, "type": "isolated", "frameId": frame_id}}}), session_id: es.clone() },
|
||||
CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "commit", "timestamp": ts}), session_id: es.clone() },
|
||||
];
|
||||
ctx.pending_events.extend(phase1);
|
||||
|
||||
if ctx.fetch_intercept.enabled {
|
||||
for net_event in &network_events {
|
||||
ctx.pending_events.push(CdpEvent {
|
||||
method: "Fetch.requestPaused".into(),
|
||||
params: json!({
|
||||
"requestId": net_event.request_id,
|
||||
"request": {
|
||||
"url": net_event.url,
|
||||
"method": net_event.method,
|
||||
"headers": net_event.headers,
|
||||
},
|
||||
"frameId": frame_id,
|
||||
"resourceType": net_event.resource_type,
|
||||
"networkId": net_event.request_id,
|
||||
}),
|
||||
session_id: es.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for net_event in &network_events {
|
||||
ctx.pending_events.push(CdpEvent {
|
||||
method: "Network.requestWillBeSent".into(),
|
||||
params: json!({"requestId": net_event.request_id, "loaderId": loader_id, "documentURL": page_url, "request": {"url": net_event.url, "method": net_event.method, "headers": net_event.headers}, "timestamp": net_event.timestamp, "wallTime": net_event.timestamp, "initiator": {"type": "other"}, "type": net_event.resource_type, "frameId": frame_id}),
|
||||
session_id: es.clone(),
|
||||
});
|
||||
ctx.pending_events.push(CdpEvent {
|
||||
method: "Network.responseReceived".into(),
|
||||
params: json!({"requestId": net_event.request_id, "loaderId": loader_id, "timestamp": net_event.timestamp, "type": net_event.resource_type, "response": {"url": net_event.url, "status": net_event.status, "statusText": "", "headers": &*net_event.response_headers, "mimeType": net_event.response_headers.get("content-type").cloned().unwrap_or_default()}, "frameId": frame_id}),
|
||||
session_id: es.clone(),
|
||||
});
|
||||
ctx.pending_events.push(CdpEvent {
|
||||
method: "Network.loadingFinished".into(),
|
||||
params: json!({"requestId": net_event.request_id, "timestamp": net_event.timestamp, "encodedDataLength": net_event.body_size}),
|
||||
session_id: es.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut phase3 = vec![
|
||||
CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "DOMContentLoaded", "timestamp": ts}), session_id: es.clone() },
|
||||
CdpEvent { method: "Page.domContentEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() },
|
||||
CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "load", "timestamp": ts}), session_id: es.clone() },
|
||||
CdpEvent { method: "Page.loadEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() },
|
||||
];
|
||||
if reached_network_idle || matches!(wait_until, WaitUntil::Load | WaitUntil::DomContentLoaded) {
|
||||
let idle_ts = timestamp();
|
||||
phase3.push(CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "networkIdle", "timestamp": idle_ts}), session_id: es.clone() });
|
||||
}
|
||||
phase3.push(CdpEvent { method: "Page.frameStoppedLoading".into(), params: json!({"frameId": frame_id}), session_id: es });
|
||||
ctx.pending_events.extend(phase3);
|
||||
|
||||
Ok(json!({
|
||||
"frameId": frame_id,
|
||||
"loaderId": loader_id,
|
||||
}))
|
||||
}
|
||||
"getFrameTree" => {
|
||||
let page = ctx.get_session_page(session_id).ok_or("No page for session")?;
|
||||
Ok(json!({
|
||||
"frameTree": {
|
||||
"frame": {
|
||||
"id": page.frame_id,
|
||||
"loaderId": "initial-loader",
|
||||
"url": page.url_string(),
|
||||
"domainAndRegistry": "",
|
||||
"securityOrigin": page.url_string(),
|
||||
"mimeType": "text/html",
|
||||
"adFrameStatus": { "adFrameType": "none" },
|
||||
},
|
||||
"childFrames": [],
|
||||
}
|
||||
}))
|
||||
}
|
||||
"createIsolatedWorld" => {
|
||||
let page = ctx.get_session_page(session_id).ok_or("No page for session")?;
|
||||
let frame_id_param = params.get("frameId").and_then(|v| v.as_str())
|
||||
.unwrap_or(&page.frame_id).to_string();
|
||||
let world_name = params.get("worldName").and_then(|v| v.as_str())
|
||||
.unwrap_or("").to_string();
|
||||
let page_url = page.url_string();
|
||||
let page_id = page.id.clone();
|
||||
let context_id = 100;
|
||||
|
||||
ctx.pending_events.push(CdpEvent {
|
||||
method: "Runtime.executionContextCreated".to_string(),
|
||||
params: json!({
|
||||
"context": {
|
||||
"id": context_id,
|
||||
"origin": page_url,
|
||||
"name": world_name,
|
||||
"uniqueId": format!("ctx-isolated-{}", page_id),
|
||||
"auxData": {
|
||||
"isDefault": false,
|
||||
"type": "isolated",
|
||||
"frameId": frame_id_param,
|
||||
}
|
||||
}
|
||||
}),
|
||||
session_id: session_id.clone(),
|
||||
});
|
||||
|
||||
Ok(json!({ "executionContextId": context_id }))
|
||||
}
|
||||
"setLifecycleEventsEnabled" => Ok(json!({})),
|
||||
"addScriptToEvaluateOnNewDocument" => {
|
||||
let source = params.get("source").and_then(|v| v.as_str()).unwrap_or("");
|
||||
ctx.preload_counter += 1;
|
||||
let identifier = format!("{}", ctx.preload_counter);
|
||||
if !source.is_empty() {
|
||||
ctx.preload_scripts.push((identifier.clone(), source.to_string()));
|
||||
}
|
||||
Ok(json!({ "identifier": identifier }))
|
||||
}
|
||||
"removeScriptToEvaluateOnNewDocument" => {
|
||||
let identifier = params.get("identifier").and_then(|v| v.as_str()).unwrap_or("");
|
||||
ctx.preload_scripts.retain(|(id, _)| id != identifier);
|
||||
Ok(json!({}))
|
||||
}
|
||||
"setInterceptFileChooserDialog" => Ok(json!({})),
|
||||
"getNavigationHistory" => {
|
||||
let page = ctx.get_session_page(session_id).ok_or("No page for session")?;
|
||||
Ok(json!({
|
||||
"currentIndex": 0,
|
||||
"entries": [{
|
||||
"id": 0,
|
||||
"url": page.url_string(),
|
||||
"userTypedURL": page.url_string(),
|
||||
"title": page.title,
|
||||
"transitionType": "typed",
|
||||
}]
|
||||
}))
|
||||
}
|
||||
_ => Err(format!("Unknown Page method: {}", method)),
|
||||
}
|
||||
}
|
||||
|
||||
fn timestamp() -> f64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs_f64()
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
use obscura_js::runtime::RemoteObjectInfo;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::dispatch::CdpContext;
|
||||
|
||||
pub async fn handle(
|
||||
method: &str,
|
||||
params: &Value,
|
||||
ctx: &mut CdpContext,
|
||||
session_id: &Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
match method {
|
||||
"enable" => {
|
||||
let page = ctx.get_session_page(session_id).ok_or("No page")?;
|
||||
let event = crate::types::CdpEvent {
|
||||
method: "Runtime.executionContextCreated".to_string(),
|
||||
params: json!({
|
||||
"context": {
|
||||
"id": 1,
|
||||
"origin": page.url_string(),
|
||||
"name": "",
|
||||
"uniqueId": format!("ctx-{}", page.id),
|
||||
"auxData": {
|
||||
"isDefault": true,
|
||||
"type": "default",
|
||||
"frameId": page.frame_id,
|
||||
}
|
||||
}
|
||||
}),
|
||||
session_id: session_id.clone(),
|
||||
};
|
||||
ctx.pending_events.push(event);
|
||||
Ok(json!({}))
|
||||
}
|
||||
"evaluate" => {
|
||||
let expression = params
|
||||
.get("expression")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("expression required")?;
|
||||
let return_by_value = params
|
||||
.get("returnByValue")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let page = ctx
|
||||
.get_session_page_mut(session_id)
|
||||
.ok_or("No page")?;
|
||||
let info = page.evaluate_for_cdp(expression, return_by_value);
|
||||
|
||||
Ok(json!({ "result": remote_object_from_info(&info) }))
|
||||
}
|
||||
"callFunctionOn" => {
|
||||
let function_declaration = params
|
||||
.get("functionDeclaration")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("() => undefined");
|
||||
let return_by_value = params
|
||||
.get("returnByValue")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let await_promise = params
|
||||
.get("awaitPromise")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let object_id = params.get("objectId").and_then(|v| v.as_str());
|
||||
let arguments = params
|
||||
.get("arguments")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.to_vec())
|
||||
.unwrap_or_default();
|
||||
|
||||
let page = ctx
|
||||
.get_session_page_mut(session_id)
|
||||
.ok_or("No page")?;
|
||||
let info =
|
||||
page.call_function_on_for_cdp(function_declaration, object_id, &arguments, return_by_value, await_promise).await;
|
||||
|
||||
Ok(json!({ "result": remote_object_from_info(&info) }))
|
||||
}
|
||||
"getProperties" => {
|
||||
let object_id = params.get("objectId").and_then(|v| v.as_str());
|
||||
if let Some(oid) = object_id {
|
||||
let page = ctx
|
||||
.get_session_page_mut(session_id)
|
||||
.ok_or("No page")?;
|
||||
let escaped_oid = oid.replace('\\', "\\\\").replace('\'', "\\'");
|
||||
let code = format!(
|
||||
"(function() {{\
|
||||
var obj = globalThis.__obscura_objects['{oid}'];\
|
||||
if (!obj || typeof obj !== 'object') return [];\
|
||||
return Object.keys(obj).map(function(k) {{\
|
||||
var v = obj[k];\
|
||||
return {{ name: k, value: v, type: typeof v }};\
|
||||
}});\
|
||||
}})()",
|
||||
oid = escaped_oid,
|
||||
);
|
||||
let result = page.evaluate(&code);
|
||||
if let serde_json::Value::Array(props) = result {
|
||||
let descriptors: Vec<Value> = props
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let value = p.get("value").unwrap_or(&Value::Null);
|
||||
let prop_type =
|
||||
p.get("type").and_then(|v| v.as_str()).unwrap_or("undefined");
|
||||
let mut remote = json!({
|
||||
"type": prop_type,
|
||||
});
|
||||
match value {
|
||||
Value::Null => {
|
||||
remote["type"] = json!("object");
|
||||
remote["subtype"] = json!("null");
|
||||
remote["value"] = json!(null);
|
||||
}
|
||||
Value::String(s) => {
|
||||
remote["type"] = json!("string");
|
||||
remote["value"] = json!(s);
|
||||
}
|
||||
Value::Number(n) => {
|
||||
remote["type"] = json!("number");
|
||||
remote["value"] = json!(n);
|
||||
}
|
||||
Value::Bool(b) => {
|
||||
remote["type"] = json!("boolean");
|
||||
remote["value"] = json!(b);
|
||||
}
|
||||
_ => {
|
||||
remote["value"] = value.clone();
|
||||
}
|
||||
}
|
||||
json!({
|
||||
"name": name,
|
||||
"value": remote,
|
||||
"configurable": true,
|
||||
"enumerable": true,
|
||||
"writable": true,
|
||||
"isOwn": true,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "result": descriptors, "internalProperties": [] }))
|
||||
} else {
|
||||
Ok(json!({ "result": [], "internalProperties": [] }))
|
||||
}
|
||||
} else {
|
||||
Ok(json!({ "result": [], "internalProperties": [] }))
|
||||
}
|
||||
}
|
||||
"releaseObject" => {
|
||||
if let Some(oid) = params.get("objectId").and_then(|v| v.as_str()) {
|
||||
if let Some(page) = ctx.get_session_page_mut(session_id) {
|
||||
page.release_object(oid);
|
||||
}
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"releaseObjectGroup" => {
|
||||
if let Some(page) = ctx.get_session_page_mut(session_id) {
|
||||
page.release_object_group();
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"addBinding" => {
|
||||
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if !name.is_empty() {
|
||||
if name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '$')
|
||||
&& !name.chars().next().unwrap_or('0').is_ascii_digit() {
|
||||
if let Some(page) = ctx.get_session_page_mut(session_id) {
|
||||
let code = format!(
|
||||
"if (typeof globalThis.{name} === 'undefined') {{\
|
||||
globalThis.{name} = function() {{ return null; }};\
|
||||
}}",
|
||||
name = name,
|
||||
);
|
||||
page.evaluate(&code);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"runIfWaitingForDebugger" => Ok(json!({})),
|
||||
"getExceptionDetails" => Ok(json!({ "exceptionDetails": null })),
|
||||
"discardConsoleEntries" => Ok(json!({})),
|
||||
_ => Err(format!("Unknown Runtime method: {}", method)),
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_object_from_info(info: &RemoteObjectInfo) -> Value {
|
||||
let mut obj = json!({ "type": info.js_type });
|
||||
|
||||
if let Some(ref subtype) = info.subtype {
|
||||
obj["subtype"] = json!(subtype);
|
||||
}
|
||||
|
||||
if !info.class_name.is_empty() {
|
||||
obj["className"] = json!(info.class_name);
|
||||
}
|
||||
|
||||
if !info.description.is_empty() {
|
||||
obj["description"] = json!(info.description);
|
||||
}
|
||||
|
||||
if let Some(ref oid) = info.object_id {
|
||||
obj["objectId"] = json!(oid);
|
||||
}
|
||||
|
||||
if let Some(ref value) = info.value {
|
||||
obj["value"] = value.clone();
|
||||
}
|
||||
|
||||
obj
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::dispatch::CdpContext;
|
||||
|
||||
pub async fn handle(
|
||||
method: &str,
|
||||
params: &Value,
|
||||
ctx: &mut CdpContext,
|
||||
_session_id: &Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
match method {
|
||||
"getCookies" => {
|
||||
let cookies = ctx.default_context.cookie_jar.get_all_cookies();
|
||||
let cdp_cookies: Vec<Value> = cookies
|
||||
.iter()
|
||||
.map(|c| {
|
||||
json!({
|
||||
"name": c.name,
|
||||
"value": c.value,
|
||||
"domain": c.domain,
|
||||
"path": c.path,
|
||||
"expires": -1,
|
||||
"size": c.name.len() + c.value.len(),
|
||||
"httpOnly": c.http_only,
|
||||
"secure": c.secure,
|
||||
"session": true,
|
||||
"priority": "Medium",
|
||||
"sameParty": false,
|
||||
"sourceScheme": if c.secure { "Secure" } else { "NonSecure" },
|
||||
"sourcePort": 80,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "cookies": cdp_cookies }))
|
||||
}
|
||||
"setCookies" => {
|
||||
if let Some(cookies) = params.get("cookies").and_then(|v| v.as_array()) {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs_f64();
|
||||
|
||||
for c in cookies {
|
||||
let name = match c.get("name").and_then(|v| v.as_str()) {
|
||||
Some(n) => n.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
let value = c.get("value").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
|
||||
let domain = c
|
||||
.get("domain")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
c.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|u| url::Url::parse(u).ok())
|
||||
.and_then(|u| u.host_str().map(|h| h.to_string()))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let expires = c.get("expires").and_then(|v| v.as_f64());
|
||||
if let Some(exp) = expires {
|
||||
if exp > 0.0 && exp < now {
|
||||
ctx.default_context.cookie_jar.delete_cookie(&name, &domain);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let path = c
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("/")
|
||||
.to_string();
|
||||
let secure =
|
||||
c.get("secure").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let http_only =
|
||||
c.get("httpOnly").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
ctx.default_context.cookie_jar.set_cookies_from_cdp(vec![
|
||||
obscura_net::CookieInfo {
|
||||
name,
|
||||
value,
|
||||
domain,
|
||||
path,
|
||||
secure,
|
||||
http_only,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"deleteCookies" => {
|
||||
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let domain_from_url = params
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|u| url::Url::parse(u).ok())
|
||||
.and_then(|u| u.host_str().map(|h| h.to_string()));
|
||||
let domain = params
|
||||
.get("domain")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or(domain_from_url)
|
||||
.unwrap_or_default();
|
||||
|
||||
if !name.is_empty() {
|
||||
ctx.default_context
|
||||
.cookie_jar
|
||||
.delete_cookie(name, &domain);
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
_ => Ok(json!({})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::dispatch::CdpContext;
|
||||
use crate::types::CdpEvent;
|
||||
|
||||
pub async fn handle(method: &str, params: &Value, ctx: &mut CdpContext) -> Result<Value, String> {
|
||||
match method {
|
||||
"setDiscoverTargets" => {
|
||||
ctx.pending_events.push(CdpEvent::new(
|
||||
"Target.targetCreated",
|
||||
json!({
|
||||
"targetInfo": {
|
||||
"targetId": "browser",
|
||||
"type": "browser",
|
||||
"title": "",
|
||||
"url": "",
|
||||
"attached": true,
|
||||
"browserContextId": "",
|
||||
}
|
||||
}),
|
||||
));
|
||||
for page in &ctx.pages {
|
||||
ctx.pending_events.push(CdpEvent::new(
|
||||
"Target.targetCreated",
|
||||
json!({
|
||||
"targetInfo": {
|
||||
"targetId": page.id,
|
||||
"type": "page",
|
||||
"title": page.title,
|
||||
"url": page.url_string(),
|
||||
"attached": false,
|
||||
"browserContextId": page.context.id,
|
||||
}
|
||||
}),
|
||||
));
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"getTargets" => {
|
||||
let targets: Vec<Value> = ctx
|
||||
.pages
|
||||
.iter()
|
||||
.map(|page| {
|
||||
json!({
|
||||
"targetId": page.id,
|
||||
"type": "page",
|
||||
"title": page.title,
|
||||
"url": page.url_string(),
|
||||
"attached": true,
|
||||
"browserContextId": page.context.id,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "targetInfos": targets }))
|
||||
}
|
||||
"createTarget" => {
|
||||
let url = params.get("url").and_then(|v| v.as_str()).unwrap_or("about:blank");
|
||||
let page_id = ctx.create_page();
|
||||
let session_id = format!("{}-session", page_id);
|
||||
|
||||
if let Some(page) = ctx.get_page_mut(&page_id) {
|
||||
if url == "about:blank" || url.is_empty() {
|
||||
page.navigate_blank();
|
||||
} else {
|
||||
let _ = page.navigate(url).await;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.sessions.insert(session_id.clone(), page_id.clone());
|
||||
|
||||
if let Some(page) = ctx.get_page(&page_id) {
|
||||
ctx.pending_events.push(CdpEvent::new(
|
||||
"Target.targetCreated",
|
||||
json!({
|
||||
"targetInfo": {
|
||||
"targetId": page_id,
|
||||
"type": "page",
|
||||
"title": page.title,
|
||||
"url": page.url_string(),
|
||||
"attached": false,
|
||||
"browserContextId": page.context.id,
|
||||
}
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(page) = ctx.get_page(&page_id) {
|
||||
ctx.pending_events.push(CdpEvent::new(
|
||||
"Target.attachedToTarget",
|
||||
json!({
|
||||
"sessionId": session_id,
|
||||
"targetInfo": {
|
||||
"targetId": page_id,
|
||||
"type": "page",
|
||||
"title": page.title,
|
||||
"url": page.url_string(),
|
||||
"attached": true,
|
||||
"browserContextId": page.context.id,
|
||||
},
|
||||
"waitingForDebugger": false,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(json!({ "targetId": page_id }))
|
||||
}
|
||||
"attachToTarget" => {
|
||||
let target_id = params.get("targetId").and_then(|v| v.as_str())
|
||||
.ok_or("targetId required")?;
|
||||
let session_id = format!("{}-session", target_id);
|
||||
ctx.sessions.insert(session_id.clone(), target_id.to_string());
|
||||
|
||||
if let Some(page) = ctx.get_page(target_id) {
|
||||
ctx.pending_events.push(CdpEvent::new(
|
||||
"Target.attachedToTarget",
|
||||
json!({
|
||||
"sessionId": session_id,
|
||||
"targetInfo": {
|
||||
"targetId": target_id,
|
||||
"type": "page",
|
||||
"title": page.title,
|
||||
"url": page.url_string(),
|
||||
"attached": true,
|
||||
"browserContextId": page.context.id,
|
||||
},
|
||||
"waitingForDebugger": false,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(json!({ "sessionId": session_id }))
|
||||
}
|
||||
"closeTarget" => {
|
||||
let target_id = params.get("targetId").and_then(|v| v.as_str())
|
||||
.ok_or("targetId required")?;
|
||||
let session_id = format!("{}-session", target_id);
|
||||
|
||||
ctx.pending_events.push(CdpEvent::new(
|
||||
"Target.detachedFromTarget",
|
||||
json!({
|
||||
"sessionId": session_id,
|
||||
"targetId": target_id,
|
||||
}),
|
||||
));
|
||||
ctx.pending_events.push(CdpEvent::new(
|
||||
"Target.targetDestroyed",
|
||||
json!({ "targetId": target_id }),
|
||||
));
|
||||
|
||||
ctx.remove_page(target_id);
|
||||
Ok(json!({ "success": true }))
|
||||
}
|
||||
"setAutoAttach" => Ok(json!({})),
|
||||
"getBrowserContexts" => {
|
||||
Ok(json!({ "browserContextIds": [ctx.default_context.id] }))
|
||||
}
|
||||
"createBrowserContext" => {
|
||||
ctx.default_context.cookie_jar.clear();
|
||||
Ok(json!({ "browserContextId": ctx.default_context.id }))
|
||||
}
|
||||
"disposeBrowserContext" => {
|
||||
ctx.default_context.cookie_jar.clear();
|
||||
Ok(json!({}))
|
||||
}
|
||||
"getTargetInfo" => {
|
||||
let target_id = params.get("targetId").and_then(|v| v.as_str());
|
||||
match target_id {
|
||||
Some(id) => {
|
||||
let page = ctx.get_page(id).ok_or("Target not found")?;
|
||||
Ok(json!({
|
||||
"targetInfo": {
|
||||
"targetId": id,
|
||||
"type": "page",
|
||||
"title": page.title,
|
||||
"url": page.url_string(),
|
||||
"attached": true,
|
||||
"browserContextId": page.context.id,
|
||||
}
|
||||
}))
|
||||
}
|
||||
None => {
|
||||
Ok(json!({
|
||||
"targetInfo": {
|
||||
"targetId": "browser",
|
||||
"type": "browser",
|
||||
"title": "",
|
||||
"url": "",
|
||||
"attached": true,
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => Err(format!("Unknown Target method: {}", method)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod server;
|
||||
pub mod dispatch;
|
||||
pub mod types;
|
||||
pub mod domains;
|
||||
|
||||
pub use server::{start, start_with_options};
|
||||
@@ -0,0 +1,629 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::json;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::dispatch::{self, CdpContext};
|
||||
use crate::types::CdpRequest;
|
||||
|
||||
struct CdpMessage {
|
||||
text: String,
|
||||
reply_tx: mpsc::UnboundedSender<String>,
|
||||
}
|
||||
|
||||
enum ServerMessage {
|
||||
Cdp(CdpMessage),
|
||||
NewConnection {
|
||||
reply_tx: mpsc::UnboundedSender<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn start(port: u16) -> anyhow::Result<()> {
|
||||
start_with_options(port, None).await
|
||||
}
|
||||
|
||||
pub async fn start_with_options(port: u16, proxy: Option<String>) -> anyhow::Result<()> {
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
let listener = TcpListener::bind(&addr).await?;
|
||||
|
||||
info!("Obscura CDP server listening on ws://127.0.0.1:{}", port);
|
||||
info!(
|
||||
"DevTools endpoint: ws://127.0.0.1:{}/devtools/browser",
|
||||
port
|
||||
);
|
||||
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let (msg_tx, msg_rx) = mpsc::unbounded_channel::<ServerMessage>();
|
||||
|
||||
let processor_handle = tokio::task::spawn_local(cdp_processor(msg_rx, proxy));
|
||||
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((stream, peer_addr)) => {
|
||||
info!("New connection from {}", peer_addr);
|
||||
let tx = msg_tx.clone();
|
||||
tokio::task::spawn_local(async move {
|
||||
if let Err(e) = handle_connection(stream, port, tx).await {
|
||||
if !format!("{}", e).contains("close") {
|
||||
error!("Connection error from {}: {}", peer_addr, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => error!("Accept error: {}", e),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn cdp_processor(
|
||||
mut rx: mpsc::UnboundedReceiver<ServerMessage>,
|
||||
proxy: Option<String>,
|
||||
) {
|
||||
let mut ctx = CdpContext::new_with_proxy(proxy);
|
||||
let (itx, irx) = mpsc::unbounded_channel::<obscura_js::ops::InterceptedRequest>();
|
||||
ctx.intercept_tx = Some(itx);
|
||||
let mut intercept_rx: Option<mpsc::UnboundedReceiver<obscura_js::ops::InterceptedRequest>> = Some(irx);
|
||||
let mut intercepted_paused: HashMap<String, tokio::sync::oneshot::Sender<obscura_js::ops::InterceptResolution>> = HashMap::new();
|
||||
|
||||
while let Some(msg) = rx.recv().await {
|
||||
match msg {
|
||||
ServerMessage::NewConnection { reply_tx } => {
|
||||
let _ = reply_tx.send(
|
||||
json!({"__init": true})
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
ServerMessage::Cdp(cdp_msg) => {
|
||||
let is_navigation = cdp_msg.text.contains("Page.navigate");
|
||||
let has_interception = ctx.fetch_intercept.enabled;
|
||||
|
||||
if is_navigation && has_interception {
|
||||
process_with_interception(
|
||||
&cdp_msg.text, &mut ctx, &cdp_msg.reply_tx, &mut rx,
|
||||
&mut intercept_rx, &mut intercepted_paused,
|
||||
).await;
|
||||
} else {
|
||||
if cdp_msg.text.contains("Fetch.") {
|
||||
handle_fetch_resolution(&cdp_msg.text, &mut ctx, &cdp_msg.reply_tx, &mut intercepted_paused);
|
||||
}
|
||||
process_cdp_message(&cdp_msg.text, &mut ctx, &cdp_msg.reply_tx).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_fetch_resolution(
|
||||
text: &str,
|
||||
ctx: &mut CdpContext,
|
||||
reply_tx: &mpsc::UnboundedSender<String>,
|
||||
intercepted_paused: &mut HashMap<String, tokio::sync::oneshot::Sender<obscura_js::ops::InterceptResolution>>,
|
||||
) {
|
||||
if let Ok(req) = serde_json::from_str::<CdpRequest>(text) {
|
||||
let method = req.method.as_str();
|
||||
let request_id = req.params.get("requestId").and_then(|v| v.as_str()).unwrap_or("");
|
||||
tracing::info!("INTERCEPTION resolution: {} for {}, paused_count={}", method, request_id, intercepted_paused.len());
|
||||
|
||||
if let Some(resolver) = intercepted_paused.remove(request_id) {
|
||||
tracing::info!("INTERCEPTION resolved: {}", request_id);
|
||||
let resolution = match method {
|
||||
"Fetch.continueRequest" => obscura_js::ops::InterceptResolution::Continue {
|
||||
url: None, method: None, headers: None, body: None,
|
||||
},
|
||||
"Fetch.fulfillRequest" => {
|
||||
let status = req.params.get("responseCode").and_then(|v| v.as_u64()).unwrap_or(200) as u16;
|
||||
let raw_body = req.params.get("body").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let body = decode_base64(raw_body);
|
||||
let headers = req.params.get("responseHeaders")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|h| {
|
||||
Some((h.get("name")?.as_str()?.to_string(), h.get("value")?.as_str()?.to_string()))
|
||||
}).collect())
|
||||
.unwrap_or_default();
|
||||
obscura_js::ops::InterceptResolution::Fulfill { status, headers, body }
|
||||
}
|
||||
"Fetch.failRequest" => {
|
||||
let reason = req.params.get("errorReason").and_then(|v| v.as_str()).unwrap_or("Failed").to_string();
|
||||
obscura_js::ops::InterceptResolution::Fail { reason }
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
let _ = resolver.send(resolution);
|
||||
let resp = crate::types::CdpResponse::success(req.id, json!({}), req.session_id);
|
||||
if let Ok(json) = serde_json::to_string(&resp) {
|
||||
let _ = reply_tx.send(json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_with_interception(
|
||||
text: &str,
|
||||
ctx: &mut CdpContext,
|
||||
reply_tx: &mpsc::UnboundedSender<String>,
|
||||
rx: &mut mpsc::UnboundedReceiver<ServerMessage>,
|
||||
intercept_rx: &mut Option<mpsc::UnboundedReceiver<obscura_js::ops::InterceptedRequest>>,
|
||||
intercepted_paused: &mut HashMap<String, tokio::sync::oneshot::Sender<obscura_js::ops::InterceptResolution>>,
|
||||
) {
|
||||
let req: CdpRequest = match serde_json::from_str(text) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!("Invalid CDP: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("INTERCEPTION navigate: {} (id={})", req.method, req.id);
|
||||
|
||||
let session_id = &req.session_id;
|
||||
let page_id = session_id
|
||||
.as_ref()
|
||||
.and_then(|sid| ctx.sessions.get(sid))
|
||||
.cloned();
|
||||
|
||||
let page_id = match page_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
process_cdp_message(text, ctx, reply_tx).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let page_index = ctx.pages.iter().position(|p| p.id == page_id);
|
||||
let mut page = match page_index {
|
||||
Some(idx) => ctx.pages.remove(idx),
|
||||
None => {
|
||||
process_cdp_message(text, ctx, reply_tx).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let url = req.params.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let wait_until = req.params.get("waitUntil")
|
||||
.and_then(|v| {
|
||||
if let Some(s) = v.as_str() {
|
||||
Some(obscura_browser::WaitUntil::from_str(s))
|
||||
} else if let Some(arr) = v.as_array() {
|
||||
arr.iter()
|
||||
.filter_map(|item| item.as_str())
|
||||
.map(obscura_browser::WaitUntil::from_str)
|
||||
.max_by_key(|w| match w {
|
||||
obscura_browser::WaitUntil::DomContentLoaded => 0,
|
||||
obscura_browser::WaitUntil::Load => 1,
|
||||
obscura_browser::WaitUntil::NetworkIdle2 => 2,
|
||||
obscura_browser::WaitUntil::NetworkIdle0 => 3,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(obscura_browser::WaitUntil::Load);
|
||||
|
||||
let preload_scripts: Vec<String> = ctx.preload_scripts.iter().map(|(_, s)| s.clone()).collect();
|
||||
|
||||
if let Some(tx) = &ctx.intercept_tx {
|
||||
page.set_intercept_tx(tx.clone());
|
||||
}
|
||||
|
||||
let session_for_events = req.session_id.clone();
|
||||
let frame_id = page.frame_id.clone();
|
||||
let loader_id = format!("loader-{}", uuid::Uuid::new_v4());
|
||||
|
||||
let (nav_done_tx, mut nav_done_rx) = mpsc::channel::<(obscura_browser::Page, Result<(), String>)>(1);
|
||||
let url_owned = url.to_string();
|
||||
|
||||
tokio::task::spawn_local(async move {
|
||||
let result = page.navigate_with_wait(&url_owned, wait_until).await.map_err(|e| e.to_string());
|
||||
for source in &preload_scripts {
|
||||
if let Err(e) = page.execute_preload_script(source) {
|
||||
tracing::debug!("Preload script error: {}", e);
|
||||
}
|
||||
}
|
||||
let _ = nav_done_tx.send((page, result)).await;
|
||||
});
|
||||
|
||||
let mut navigate_result: Result<(), String> = Ok(());
|
||||
let mut page_back: Option<obscura_browser::Page> = None;
|
||||
|
||||
loop {
|
||||
let has_irx = intercept_rx.is_some();
|
||||
|
||||
tokio::select! {
|
||||
Some((returned_page, result)) = nav_done_rx.recv() => {
|
||||
page_back = Some(returned_page);
|
||||
navigate_result = result;
|
||||
break;
|
||||
}
|
||||
Some(intercepted) = async {
|
||||
if let Some(ref mut irx) = intercept_rx {
|
||||
irx.recv().await
|
||||
} else {
|
||||
std::future::pending().await
|
||||
}
|
||||
}, if has_irx => {
|
||||
tracing::info!("INTERCEPTION: requestPaused for {} {} (sending to client)", intercepted.method, intercepted.url);
|
||||
let rws_event = json!({
|
||||
"method": "Network.requestWillBeSent",
|
||||
"params": {
|
||||
"requestId": intercepted.request_id,
|
||||
"loaderId": "",
|
||||
"documentURL": "",
|
||||
"request": {
|
||||
"url": intercepted.url,
|
||||
"method": intercepted.method,
|
||||
"headers": intercepted.headers,
|
||||
"initialPriority": "High",
|
||||
"referrerPolicy": "strict-origin-when-cross-origin",
|
||||
},
|
||||
"timestamp": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs_f64(),
|
||||
"wallTime": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs_f64(),
|
||||
"initiator": {"type": "script"},
|
||||
"type": intercepted.resource_type,
|
||||
"frameId": frame_id,
|
||||
},
|
||||
"sessionId": session_for_events,
|
||||
});
|
||||
let _ = reply_tx.send(rws_event.to_string());
|
||||
|
||||
let event_json = json!({
|
||||
"method": "Fetch.requestPaused",
|
||||
"params": {
|
||||
"requestId": intercepted.request_id,
|
||||
"request": {
|
||||
"url": intercepted.url,
|
||||
"method": intercepted.method,
|
||||
"headers": intercepted.headers,
|
||||
"initialPriority": "High",
|
||||
"referrerPolicy": "strict-origin-when-cross-origin",
|
||||
},
|
||||
"frameId": frame_id,
|
||||
"resourceType": intercepted.resource_type,
|
||||
"networkId": intercepted.request_id,
|
||||
"responseErrorReason": null,
|
||||
"responseStatusCode": null,
|
||||
"responseHeaders": null,
|
||||
},
|
||||
"sessionId": session_for_events,
|
||||
});
|
||||
let event_str = event_json.to_string();
|
||||
tracing::info!("INTERCEPTION event JSON: {}", &event_str[..event_str.len().min(300)]);
|
||||
let _ = reply_tx.send(event_str);
|
||||
intercepted_paused.insert(intercepted.request_id.clone(), intercepted.resolver);
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
Some(msg) = rx.recv() => {
|
||||
tracing::info!("INTERCEPTION select: received CDP message during navigation");
|
||||
match msg {
|
||||
ServerMessage::NewConnection { reply_tx: new_tx } => {
|
||||
let pid = ctx.create_page();
|
||||
let sid = format!("{}-session", pid);
|
||||
ctx.sessions.insert(sid.clone(), pid.clone());
|
||||
let _ = new_tx.send(json!({"__init": true, "pageId": pid, "sessionId": sid}).to_string());
|
||||
}
|
||||
ServerMessage::Cdp(msg) => {
|
||||
if msg.text.contains("Fetch.") {
|
||||
handle_fetch_resolution(&msg.text, ctx, &msg.reply_tx, intercepted_paused);
|
||||
} else {
|
||||
process_cdp_message(&msg.text, ctx, &msg.reply_tx).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut page = page_back.expect("navigation task should return the page");
|
||||
|
||||
let network_events: Vec<_> = page.network_events.drain(..).collect();
|
||||
let page_url = page.url_string();
|
||||
let page_id_for_events = page.id.clone();
|
||||
let reached_network_idle = page.lifecycle.is_network_idle();
|
||||
|
||||
ctx.pages.push(page);
|
||||
|
||||
let response = match navigate_result {
|
||||
Ok(()) => crate::types::CdpResponse::success(
|
||||
req.id,
|
||||
json!({"frameId": frame_id, "loaderId": loader_id}),
|
||||
req.session_id.clone(),
|
||||
),
|
||||
Err(e) => crate::types::CdpResponse::error(req.id, -32000, e, req.session_id.clone()),
|
||||
};
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&response) {
|
||||
let _ = reply_tx.send(json);
|
||||
}
|
||||
|
||||
let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs_f64();
|
||||
let es = session_for_events;
|
||||
|
||||
for event in [
|
||||
crate::types::CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "init", "timestamp": ts}), session_id: es.clone() },
|
||||
crate::types::CdpEvent { method: "Runtime.executionContextsCleared".into(), params: json!({}), session_id: es.clone() },
|
||||
crate::types::CdpEvent { method: "Page.frameNavigated".into(), params: json!({"frame": {"id": frame_id, "loaderId": loader_id, "url": page_url, "domainAndRegistry": "", "securityOrigin": page_url, "mimeType": "text/html", "adFrameStatus": {"adFrameType": "none"}}, "type": "Navigation"}), session_id: es.clone() },
|
||||
crate::types::CdpEvent { method: "Runtime.executionContextCreated".into(), params: json!({"context": {"id": 2, "origin": page_url, "name": "", "uniqueId": format!("ctx-nav-{}", page_id_for_events), "auxData": {"isDefault": true, "type": "default", "frameId": frame_id}}}), session_id: es.clone() },
|
||||
crate::types::CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "commit", "timestamp": ts}), session_id: es.clone() },
|
||||
] {
|
||||
if let Ok(json) = serde_json::to_string(&event) { let _ = reply_tx.send(json); }
|
||||
}
|
||||
|
||||
for net_event in &network_events {
|
||||
for event in [
|
||||
crate::types::CdpEvent { method: "Network.requestWillBeSent".into(), params: json!({"requestId": net_event.request_id, "loaderId": loader_id, "documentURL": page_url, "request": {"url": net_event.url, "method": net_event.method, "headers": net_event.headers}, "timestamp": net_event.timestamp, "wallTime": net_event.timestamp, "initiator": {"type": "other"}, "type": net_event.resource_type, "frameId": frame_id}), session_id: es.clone() },
|
||||
crate::types::CdpEvent { method: "Network.responseReceived".into(), params: json!({"requestId": net_event.request_id, "loaderId": loader_id, "timestamp": net_event.timestamp, "type": net_event.resource_type, "response": {"url": net_event.url, "status": net_event.status, "statusText": "", "headers": &*net_event.response_headers, "mimeType": ""}, "frameId": frame_id}), session_id: es.clone() },
|
||||
crate::types::CdpEvent { method: "Network.loadingFinished".into(), params: json!({"requestId": net_event.request_id, "timestamp": net_event.timestamp, "encodedDataLength": net_event.body_size}), session_id: es.clone() },
|
||||
] {
|
||||
if let Ok(json) = serde_json::to_string(&event) { let _ = reply_tx.send(json); }
|
||||
}
|
||||
}
|
||||
|
||||
for event in [
|
||||
crate::types::CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "DOMContentLoaded", "timestamp": ts}), session_id: es.clone() },
|
||||
crate::types::CdpEvent { method: "Page.domContentEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() },
|
||||
crate::types::CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "load", "timestamp": ts}), session_id: es.clone() },
|
||||
crate::types::CdpEvent { method: "Page.loadEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() },
|
||||
] {
|
||||
if let Ok(json) = serde_json::to_string(&event) { let _ = reply_tx.send(json); }
|
||||
}
|
||||
if reached_network_idle {
|
||||
let idle_event = crate::types::CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "networkIdle", "timestamp": ts}), session_id: es.clone() };
|
||||
if let Ok(json) = serde_json::to_string(&idle_event) { let _ = reply_tx.send(json); }
|
||||
}
|
||||
let stop_event = crate::types::CdpEvent { method: "Page.frameStoppedLoading".into(), params: json!({"frameId": frame_id}), session_id: es };
|
||||
if let Ok(json) = serde_json::to_string(&stop_event) { let _ = reply_tx.send(json); }
|
||||
}
|
||||
|
||||
async fn process_cdp_message(
|
||||
text: &str,
|
||||
ctx: &mut CdpContext,
|
||||
reply_tx: &mpsc::UnboundedSender<String>,
|
||||
) {
|
||||
let req: CdpRequest = match serde_json::from_str(text) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!("Invalid CDP: {}: {}", e, &text[..text.len().min(200)]);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::debug!("CDP: {} (id={}, s={:?})", req.method, req.id, req.session_id);
|
||||
|
||||
let response = dispatch::dispatch(&req, ctx).await;
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&response) {
|
||||
let _ = reply_tx.send(json);
|
||||
}
|
||||
|
||||
for event in ctx.pending_events.drain(..) {
|
||||
if let Ok(json) = serde_json::to_string(&event) {
|
||||
let _ = reply_tx.send(json);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((nav_url, nav_method, nav_body)) = check_pending_navigation(ctx, &req.session_id) {
|
||||
tracing::info!("JS-triggered nav: {} {} (body: {} bytes)", nav_method, nav_url, nav_body.len());
|
||||
let nav_req = CdpRequest {
|
||||
id: 0,
|
||||
method: "Page.navigate".to_string(),
|
||||
params: json!({"url": nav_url, "__method": nav_method, "__body": nav_body}),
|
||||
session_id: req.session_id.clone(),
|
||||
};
|
||||
let _ = dispatch::dispatch(&nav_req, ctx).await;
|
||||
for event in ctx.pending_events.drain(..) {
|
||||
if let Ok(json) = serde_json::to_string(&event) {
|
||||
let _ = reply_tx.send(json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_base64(input: &str) -> String {
|
||||
fn val(c: u8) -> Option<u8> {
|
||||
match c {
|
||||
b'A'..=b'Z' => Some(c - b'A'),
|
||||
b'a'..=b'z' => Some(c - b'a' + 26),
|
||||
b'0'..=b'9' => Some(c - b'0' + 52),
|
||||
b'+' => Some(62),
|
||||
b'/' => Some(63),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
let bytes: Vec<u8> = input.bytes().filter_map(val).collect();
|
||||
let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
|
||||
for chunk in bytes.chunks(4) {
|
||||
let b = [
|
||||
chunk.first().copied().unwrap_or(0),
|
||||
chunk.get(1).copied().unwrap_or(0),
|
||||
chunk.get(2).copied().unwrap_or(0),
|
||||
chunk.get(3).copied().unwrap_or(0),
|
||||
];
|
||||
out.push((b[0] << 2) | (b[1] >> 4));
|
||||
if chunk.len() > 2 { out.push((b[1] << 4) | (b[2] >> 2)); }
|
||||
if chunk.len() > 3 { out.push((b[2] << 6) | b[3]); }
|
||||
}
|
||||
String::from_utf8_lossy(&out).to_string()
|
||||
}
|
||||
|
||||
fn fast_path_response(text: &str) -> Option<String> {
|
||||
let req: CdpRequest = serde_json::from_str(text).ok()?;
|
||||
|
||||
let result = match req.method.as_str() {
|
||||
"Network.enable" | "Network.setCacheDisabled" | "Network.setRequestInterception" |
|
||||
"Page.enable" | "Page.setLifecycleEventsEnabled" | "Page.setInterceptFileChooserDialog" |
|
||||
"Runtime.runIfWaitingForDebugger" | "Runtime.discardConsoleEntries" |
|
||||
"Performance.enable" | "Log.enable" | "Security.enable" |
|
||||
"Emulation.setDeviceMetricsOverride" | "Emulation.setTouchEmulationEnabled" |
|
||||
"CSS.enable" | "Accessibility.enable" | "ServiceWorker.enable" |
|
||||
"Inspector.enable" | "Debugger.enable" | "Profiler.enable" |
|
||||
"HeapProfiler.enable" | "Overlay.enable" | "Storage.enable" |
|
||||
"Target.setAutoAttach" => {
|
||||
Some(json!({}))
|
||||
}
|
||||
"Browser.getVersion" => {
|
||||
Some(json!({
|
||||
"protocolVersion": "1.3",
|
||||
"product": "Obscura/0.1.0",
|
||||
"revision": "0",
|
||||
"userAgent": "Obscura/0.1.0",
|
||||
"jsVersion": "V8",
|
||||
}))
|
||||
}
|
||||
"Browser.setDownloadBehavior" | "Browser.getWindowBounds" => {
|
||||
Some(json!({}))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(value) = result {
|
||||
let resp = crate::types::CdpResponse::success(req.id, value, req.session_id);
|
||||
serde_json::to_string(&resp).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn check_pending_navigation(ctx: &CdpContext, session_id: &Option<String>) -> Option<(String, String, String)> {
|
||||
let page_id = session_id
|
||||
.as_ref()
|
||||
.and_then(|sid| ctx.sessions.get(sid))?;
|
||||
let page = ctx.pages.iter().find(|p| &p.id == page_id)?;
|
||||
page.take_pending_navigation()
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
stream: TcpStream,
|
||||
port: u16,
|
||||
msg_tx: mpsc::UnboundedSender<ServerMessage>,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut buf = [0u8; 4];
|
||||
stream.peek(&mut buf).await?;
|
||||
|
||||
if &buf == b"GET " {
|
||||
let mut peek_buf = [0u8; 1024];
|
||||
let n = stream.peek(&mut peek_buf).await?;
|
||||
let line = String::from_utf8_lossy(&peek_buf[..n]);
|
||||
|
||||
if line.contains("/json/version") {
|
||||
return handle_http_json(stream, port, "version").await;
|
||||
} else if line.contains("/json/list") || line.contains("/json\r\n") || line.contains("/json HTTP") {
|
||||
return handle_http_json(stream, port, "list").await;
|
||||
} else if line.contains("/json/protocol") {
|
||||
return handle_http_json(stream, port, "protocol").await;
|
||||
}
|
||||
}
|
||||
|
||||
let ws_stream = tokio_tungstenite::accept_async(stream).await?;
|
||||
info!("WebSocket connected");
|
||||
let (mut ws_sender, mut ws_receiver) = ws_stream.split();
|
||||
|
||||
let (reply_tx, mut reply_rx) = mpsc::unbounded_channel::<String>();
|
||||
|
||||
let _ = msg_tx.send(ServerMessage::NewConnection {
|
||||
reply_tx: reply_tx.clone(),
|
||||
});
|
||||
if let Some(init_msg) = reply_rx.recv().await {
|
||||
tracing::debug!("Connection init: {}", &init_msg[..init_msg.len().min(100)]);
|
||||
}
|
||||
|
||||
let send_task = tokio::task::spawn_local(async move {
|
||||
while let Some(msg) = reply_rx.recv().await {
|
||||
if msg.contains("\"__init\"") {
|
||||
continue;
|
||||
}
|
||||
if ws_sender.send(Message::Text(msg.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
while let Some(msg) = ws_receiver.next().await {
|
||||
let msg = match msg {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
warn!("WS read error: {}", e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
match msg {
|
||||
Message::Text(text) => {
|
||||
if text.contains("\"Browser.close\"") {
|
||||
if let Ok(req) = serde_json::from_str::<CdpRequest>(&text) {
|
||||
let resp = crate::types::CdpResponse::success(req.id, json!({}), None);
|
||||
if let Ok(json) = serde_json::to_string(&resp) {
|
||||
let _ = reply_tx.send(json);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(resp) = fast_path_response(&text) {
|
||||
let _ = reply_tx.send(resp);
|
||||
} else {
|
||||
let _ = msg_tx.send(ServerMessage::Cdp(CdpMessage {
|
||||
text: text.to_string(),
|
||||
reply_tx: reply_tx.clone(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
Message::Close(_) => {
|
||||
info!("WS closed by client");
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
send_task.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_http_json(stream: TcpStream, port: u16, endpoint: &str) -> anyhow::Result<()> {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let mut stream = stream;
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let _ = stream.read(&mut buf).await?;
|
||||
|
||||
let body = match endpoint {
|
||||
"version" => serde_json::to_string_pretty(&json!({
|
||||
"Browser": "Obscura/0.1.0",
|
||||
"Protocol-Version": "1.3",
|
||||
"User-Agent": "Obscura/0.1.0 (Headless Browser)",
|
||||
"V8-Version": "N/A",
|
||||
"WebKit-Version": "N/A",
|
||||
"webSocketDebuggerUrl": format!("ws://127.0.0.1:{}/devtools/browser", port),
|
||||
}))?,
|
||||
"list" => serde_json::to_string_pretty(&json!([{
|
||||
"description": "",
|
||||
"devtoolsFrontendUrl": "",
|
||||
"id": "page-1",
|
||||
"title": "",
|
||||
"type": "page",
|
||||
"url": "about:blank",
|
||||
"webSocketDebuggerUrl": format!("ws://127.0.0.1:{}/devtools/page/page-1", port),
|
||||
}]))?,
|
||||
"protocol" => {
|
||||
serde_json::to_string_pretty(&json!({ "version": { "major": "1", "minor": "3" } }))?
|
||||
}
|
||||
_ => "{}".to_string(),
|
||||
};
|
||||
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(), body,
|
||||
);
|
||||
stream.write_all(resp.as_bytes()).await?;
|
||||
stream.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CdpRequest {
|
||||
pub id: u64,
|
||||
pub method: String,
|
||||
#[serde(default)]
|
||||
pub params: serde_json::Value,
|
||||
#[serde(rename = "sessionId")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CdpResponse {
|
||||
pub id: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<CdpError>,
|
||||
#[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl CdpResponse {
|
||||
pub fn success(id: u64, result: serde_json::Value, session_id: Option<String>) -> Self {
|
||||
CdpResponse {
|
||||
id,
|
||||
result: Some(result),
|
||||
error: None,
|
||||
session_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(id: u64, code: i64, message: String, session_id: Option<String>) -> Self {
|
||||
CdpResponse {
|
||||
id,
|
||||
result: None,
|
||||
error: Some(CdpError { code, message }),
|
||||
session_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CdpError {
|
||||
pub code: i64,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CdpEvent {
|
||||
pub method: String,
|
||||
pub params: serde_json::Value,
|
||||
#[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl CdpEvent {
|
||||
pub fn new(method: &str, params: serde_json::Value) -> Self {
|
||||
CdpEvent {
|
||||
method: method.to_string(),
|
||||
params,
|
||||
session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_session(method: &str, params: serde_json::Value, session_id: String) -> Self {
|
||||
CdpEvent {
|
||||
method: method.to_string(),
|
||||
params,
|
||||
session_id: Some(session_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user