use std::collections::HashSet;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use serde_json::{Value, json};
use tokio::sync::Mutex;
use tokio::sync::broadcast::error::RecvError;
use tokio::task::AbortHandle;
use super::ChromiumTab;
use super::core::CdpCore;
use crate::Result;
use crate::codegen::{RecordedAction, RecordedScript};
pub const RECORDER_JS: &str = include_str!("assets/recorder.js");
const BINDING: &str = "__drission_record";
pub struct ChromiumRecorder {
core: Arc<CdpCore>,
state: Arc<Mutex<RecordedScript>>,
abort: Arc<StdMutex<Option<AbortHandle>>>,
script_id: Arc<StdMutex<Option<String>>>,
}
impl ChromiumRecorder {
pub(crate) fn new(core: Arc<CdpCore>) -> Self {
Self {
core,
state: Arc::new(Mutex::new(RecordedScript::new())),
abort: Arc::new(StdMutex::new(None)),
script_id: Arc::new(StdMutex::new(None)),
}
}
pub async fn start(&self) -> Result<()> {
self.stop().await?; let _ = self.core.send("Page.enable", json!({})).await;
self.core.send("Runtime.enable", json!({})).await?;
self.core
.send("Runtime.addBinding", json!({ "name": BINDING }))
.await?;
let r = self
.core
.send(
"Page.addScriptToEvaluateOnNewDocument",
json!({ "source": RECORDER_JS }),
)
.await?;
if let Some(id) = r["identifier"].as_str() {
*self.script_id.lock().unwrap() = Some(id.to_string());
}
let _ = self.core.eval_value(RECORDER_JS).await;
let _ = self
.core
.conn
.send(
"Target.setDiscoverTargets",
json!({ "discover": true }),
None,
)
.await;
let task = tokio::spawn(recorder_pump(self.core.clone(), self.state.clone()));
*self.abort.lock().unwrap() = Some(task.abort_handle());
Ok(())
}
pub fn is_recording(&self) -> bool {
self.abort.lock().unwrap().is_some()
}
pub async fn len(&self) -> usize {
self.state.lock().await.len()
}
pub async fn is_empty(&self) -> bool {
self.state.lock().await.is_empty()
}
pub async fn script(&self) -> RecordedScript {
self.state.lock().await.clone()
}
pub async fn to_rust(&self) -> String {
self.state.lock().await.to_rust()
}
pub async fn stop(&self) -> Result<RecordedScript> {
if let Some(a) = self.abort.lock().unwrap().take() {
a.abort();
}
let script_id = self.script_id.lock().unwrap().take();
if let Some(id) = script_id {
let _ = self
.core
.send(
"Page.removeScriptToEvaluateOnNewDocument",
json!({ "identifier": id }),
)
.await;
}
let _ = self
.core
.send("Runtime.removeBinding", json!({ "name": BINDING }))
.await;
Ok(self.state.lock().await.clone())
}
}
impl Drop for ChromiumRecorder {
fn drop(&mut self) {
if let Some(a) = self.abort.lock().unwrap().take() {
a.abort();
}
}
}
impl ChromiumTab {
pub fn recorder(&self) -> ChromiumRecorder {
ChromiumRecorder::new(self.core.clone())
}
}
async fn recorder_pump(core: Arc<CdpCore>, state: Arc<Mutex<RecordedScript>>) {
let mut events = core.conn.subscribe();
let mut sessions: HashSet<String> = HashSet::new();
sessions.insert(core.session_id.clone());
let mut targets: HashSet<String> = HashSet::new();
targets.insert(core.target_id.clone());
loop {
let ev = match events.recv().await {
Ok(ev) => ev,
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => break,
};
match ev.method.as_str() {
"Target.targetCreated" => {
let ti = &ev.params["targetInfo"];
if ti["type"].as_str() != Some("page") {
continue;
}
let target_id = ti["targetId"].as_str().unwrap_or_default().to_string();
if target_id.is_empty() || targets.contains(&target_id) {
continue;
}
let opener = ti["openerId"].as_str().unwrap_or_default();
if opener.is_empty() || !targets.contains(opener) {
continue;
}
if let Some(sid) = attach_and_arm(&core, &target_id).await {
targets.insert(target_id);
sessions.insert(sid);
state.lock().await.push(RecordedAction::NewTab);
}
}
"Runtime.bindingCalled" => {
if !session_known(&ev.session_id, &sessions)
|| ev.params["name"].as_str() != Some(BINDING)
{
continue;
}
let payload = ev.params["payload"].as_str().unwrap_or_default();
let parsed: Value = serde_json::from_str(payload).unwrap_or(Value::Null);
if let Some(action) = RecordedAction::from_event(&parsed) {
state.lock().await.push(action);
}
}
"Page.frameNavigated" => {
if !session_known(&ev.session_id, &sessions) {
continue;
}
let frame = &ev.params["frame"];
if frame.get("parentId").and_then(|v| v.as_str()).is_some() {
continue; }
let url = frame["url"].as_str().unwrap_or_default();
if is_recordable_url(url) {
state.lock().await.push(RecordedAction::Navigate {
url: url.to_string(),
});
}
}
_ => {}
}
}
}
fn session_known(sid: &Option<String>, sessions: &HashSet<String>) -> bool {
sid.as_deref()
.map(|s| sessions.contains(s))
.unwrap_or(false)
}
async fn attach_and_arm(core: &Arc<CdpCore>, target_id: &str) -> Option<String> {
let a = core
.conn
.send(
"Target.attachToTarget",
json!({ "targetId": target_id, "flatten": true }),
None,
)
.await
.ok()?;
let sid = a["sessionId"].as_str()?.to_string();
let send = |method: &'static str, params: Value| {
let conn = core.conn.clone();
let sid = sid.clone();
async move { conn.send(method, params, Some(&sid)).await }
};
let _ = send("Page.enable", json!({})).await;
let _ = send("Runtime.enable", json!({})).await;
let _ = send("Runtime.addBinding", json!({ "name": BINDING })).await;
let _ = send(
"Page.addScriptToEvaluateOnNewDocument",
json!({ "source": RECORDER_JS }),
)
.await;
let _ = send(
"Runtime.evaluate",
json!({ "expression": RECORDER_JS, "returnByValue": true }),
)
.await;
Some(sid)
}
fn is_recordable_url(url: &str) -> bool {
(url.starts_with("http://") || url.starts_with("https://") || url.starts_with("file://"))
&& url != "about:blank"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recordable_url_filter() {
assert!(is_recordable_url("https://example.com/"));
assert!(is_recordable_url("http://127.0.0.1:8080/a"));
assert!(is_recordable_url("file:///tmp/x.html"));
assert!(!is_recordable_url("about:blank"));
assert!(!is_recordable_url("data:text/html,<p>hi"));
assert!(!is_recordable_url("chrome://newtab/"));
assert!(!is_recordable_url(""));
}
#[test]
fn recorder_js_embedded() {
assert!(RECORDER_JS.contains("__drission_record"));
assert!(RECORDER_JS.contains("addEventListener"));
}
}