use crate::browser::views::{HarCreator, HarEntry, HarHeader, HarLog, HarRequest, HarResponse};
use crate::error::Result;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Debug, Clone)]
pub struct HarManager {
client: Arc<crate::browser::cdp::CdpClient>,
entries: Arc<Mutex<Vec<HarEntry>>>,
pending: Arc<Mutex<HashMap<String, HarEntry>>>,
capture_body: bool,
}
impl HarManager {
pub fn new(client: Arc<crate::browser::cdp::CdpClient>) -> Self {
Self {
client,
entries: Arc::new(Mutex::new(Vec::new())),
pending: Arc::new(Mutex::new(HashMap::new())),
capture_body: false,
}
}
pub fn new_with_body(client: Arc<crate::browser::cdp::CdpClient>) -> Self {
Self {
client,
entries: Arc::new(Mutex::new(Vec::new())),
pending: Arc::new(Mutex::new(HashMap::new())),
capture_body: true,
}
}
pub async fn start_capture(&self) -> Result<()> {
let _ = self
.client
.send_command("Network.enable", serde_json::json!({}))
.await;
let entries = Arc::clone(&self.entries);
let pending = Arc::clone(&self.pending);
let capture_body = self.capture_body;
let client = Arc::clone(&self.client);
let mut rx = self.client.subscribe_events().await?;
tokio::spawn(async move {
while let Ok(event) = rx.recv().await {
match event.method.as_str() {
"Network.requestWillBeSent" => {
if let Some(req_id) = event.params.get("requestId").and_then(|v| v.as_str())
&& let Some(req) = event.params.get("request") {
let method = req.get("method").and_then(|v| v.as_str()).unwrap_or("GET").to_string();
let url = req.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
let headers = parse_headers(req.get("headers"));
let post_data = req.get("postData").and_then(|v| v.as_str()).map(|s| s.to_string());
let timestamp = event.params.get("timestamp").and_then(|v| v.as_f64()).unwrap_or(0.0);
let entry = HarEntry {
started_date_time: timestamp_to_iso(timestamp),
time: 0.0,
request: HarRequest {
method,
url,
headers,
body_size: -1,
post_data,
},
response: None,
request_id: req_id.to_string(),
};
pending.lock().await.insert(req_id.to_string(), entry);
}
}
"Network.responseReceived" => {
if let Some(req_id) = event.params.get("requestId").and_then(|v| v.as_str())
&& let Some(resp) = event.params.get("response") {
let status = resp.get("status").and_then(|v| v.as_u64()).unwrap_or(0) as u16;
let status_text = resp.get("statusText").and_then(|v| v.as_str()).unwrap_or("").to_string();
let headers = parse_headers(resp.get("headers"));
let encoded_len = resp.get("encodedDataLength").and_then(|v| v.as_i64()).unwrap_or(-1);
let mut guard = pending.lock().await;
if let Some(entry) = guard.get_mut(req_id) {
entry.response = Some(HarResponse {
status,
status_text,
headers,
body_size: encoded_len,
body: None,
});
}
}
}
"Network.loadingFinished" => {
if let Some(req_id) = event.params.get("requestId").and_then(|v| v.as_str()) {
let timestamp = event.params.get("timestamp").and_then(|v| v.as_f64()).unwrap_or(0.0);
let body = if capture_body {
match client.send_command(
"Network.getResponseBody",
serde_json::json!({ "requestId": req_id }),
).await {
Ok(result) => {
let body_text = result.get("body").and_then(|v| v.as_str()).map(|s| s.to_string());
let base64 = result.get("base64Encoded").and_then(|v| v.as_bool()).unwrap_or(false);
if base64 {
body_text.and_then(|b| {
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &b).ok().map(|d| String::from_utf8_lossy(&d).to_string())
})
} else {
body_text
}
}
Err(_) => None,
}
} else {
None
};
let mut guard = pending.lock().await;
if let Some(entry) = guard.remove(req_id) {
let mut finished = entry;
if let Some(ref mut resp) = finished.response {
resp.body = body;
}
let start_ts = iso_to_timestamp(&finished.started_date_time).unwrap_or(0.0);
if timestamp > start_ts {
finished.time = (timestamp - start_ts) * 1000.0;
}
entries.lock().await.push(finished);
}
}
}
"Network.loadingFailed" => {
if let Some(req_id) = event.params.get("requestId").and_then(|v| v.as_str()) {
let mut guard = pending.lock().await;
if let Some(entry) = guard.remove(req_id) {
let mut finished = entry;
finished.time = 0.0;
entries.lock().await.push(finished);
}
}
}
_ => {}
}
}
});
Ok(())
}
pub async fn export_har(&self) -> HarLog {
let entries = self.entries.lock().await.clone();
HarLog {
version: "1.2".to_string(),
creator: HarCreator {
name: "browsing".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
entries,
}
}
pub async fn export_json(&self) -> Result<String> {
let har = self.export_har().await;
serde_json::to_string_pretty(&har)
.map_err(|e| crate::error::BrowsingError::Browser(format!("HAR serialize: {e}")))
}
pub async fn entry_count(&self) -> usize {
self.entries.lock().await.len()
}
pub async fn clear(&self) {
self.entries.lock().await.clear();
self.pending.lock().await.clear();
}
}
fn parse_headers(headers_val: Option<&serde_json::Value>) -> Vec<HarHeader> {
let mut result = Vec::new();
if let Some(obj) = headers_val.and_then(|v| v.as_object()) {
for (name, value) in obj {
result.push(HarHeader {
name: name.clone(),
value: value.as_str().unwrap_or("").to_string(),
});
}
}
result
}
fn timestamp_to_iso(ts: f64) -> String {
let secs = ts as i64;
let nanos = ((ts - secs as f64) * 1_000_000_000.0) as u32;
match chrono::DateTime::from_timestamp(secs, nanos) {
Some(dt) => dt.to_rfc3339(),
None => "1970-01-01T00:00:00Z".to_string(),
}
}
fn iso_to_timestamp(iso: &str) -> Option<f64> {
chrono::DateTime::parse_from_rfc3339(iso)
.ok()
.map(|dt| dt.timestamp() as f64 + dt.timestamp_subsec_nanos() as f64 / 1_000_000_000.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_har_header_creation() {
let h = HarHeader {
name: "Content-Type".to_string(),
value: "text/html".to_string(),
};
assert_eq!(h.name, "Content-Type");
assert_eq!(h.value, "text/html");
}
#[test]
fn test_har_entry_creation() {
let entry = HarEntry {
started_date_time: "2024-01-01T00:00:00Z".to_string(),
time: 123.0,
request: HarRequest {
method: "GET".to_string(),
url: "https://example.com".to_string(),
headers: vec![],
body_size: -1,
post_data: None,
},
response: Some(HarResponse {
status: 200,
status_text: "OK".to_string(),
headers: vec![],
body_size: 1024,
body: Some("<!DOCTYPE html><html></html>".to_string()),
}),
request_id: "req-1".to_string(),
};
assert_eq!(entry.request.method, "GET");
assert_eq!(entry.response.as_ref().unwrap().status, 200);
assert_eq!(entry.response.as_ref().unwrap().body.as_ref().unwrap(), "<!DOCTYPE html><html></html>");
}
#[test]
fn test_har_log_serialization() {
let log = HarLog {
version: "1.2".to_string(),
creator: HarCreator {
name: "test".to_string(),
version: "1.0".to_string(),
},
entries: vec![],
};
let json = serde_json::to_string(&log).unwrap();
assert!(json.contains("1.2"));
assert!(json.contains("test"));
}
#[test]
fn test_timestamp_to_iso_roundtrip() {
let ts = 1700000000.5;
let iso = timestamp_to_iso(ts);
let back = iso_to_timestamp(&iso).unwrap();
assert!((back - ts).abs() < 0.001);
}
#[test]
fn test_parse_headers_from_json() {
let json = serde_json::json!({"Content-Type": "text/html", "Content-Length": "42"});
let headers = parse_headers(Some(&json));
assert_eq!(headers.len(), 2);
}
}