use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use chromiumoxide::cdp::CdpEvent;
use futures::StreamExt;
pub const RING_CAPACITY: usize = 10;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BrowserEvent {
pub ts: String,
pub summary: String,
}
#[derive(Debug, Clone, Default)]
pub struct EventLog {
inner: Arc<Mutex<VecDeque<BrowserEvent>>>,
}
impl EventLog {
pub fn push(&self, summary: String) {
let ts = chrono::Local::now().format("%H:%M:%S").to_string();
let mut guard = self.inner.lock().unwrap();
if guard.len() >= RING_CAPACITY {
guard.pop_front();
}
guard.push_back(BrowserEvent { ts, summary });
}
pub fn take(&self) -> Vec<BrowserEvent> {
let mut guard = self.inner.lock().unwrap();
guard.drain(..).collect()
}
pub fn take_formatted(&self) -> Option<String> {
let events = self.take();
if events.is_empty() {
return None;
}
let rendered: Vec<String> = events
.iter()
.map(|e| format!("{} {}", e.ts, e.summary))
.collect();
Some(format!("recent_events: [{}]", rendered.join(", ")))
}
#[cfg(test)]
pub fn len(&self) -> usize {
self.inner.lock().unwrap().len()
}
}
pub fn map_cdp_event(event: &CdpEvent) -> Option<String> {
match event {
CdpEvent::PageJavascriptDialogOpening(d) => {
let kind = dialog_kind(d);
let msg = truncate(&d.message, 40);
Some(format!("js-dialog opened ({kind}): \"{msg}\""))
}
CdpEvent::BrowserDownloadWillBegin(d) => {
let name = d.suggested_filename.clone();
let name = if name.is_empty() {
truncate(&d.url, 40)
} else {
name
};
Some(format!("download started: {name}"))
}
CdpEvent::TargetTargetCreated(t) => {
let kind = t.target_info.r#type.clone();
if matches!(kind.as_str(), "page" | "background_page" | "webview") {
Some(format!("new target opened ({kind})"))
} else {
None
}
}
CdpEvent::TargetTargetCrashed(_) => Some("renderer target crashed".into()),
CdpEvent::InspectorDetached(d) => Some(format!("inspector detached ({})", d.reason)),
_ => None,
}
}
fn dialog_kind(
d: &chromiumoxide::cdp::browser_protocol::page::EventJavascriptDialogOpening,
) -> String {
use chromiumoxide::cdp::browser_protocol::page::DialogType;
match d.r#type {
DialogType::Alert => "alert".into(),
DialogType::Confirm => "confirm".into(),
DialogType::Prompt => "prompt".into(),
DialogType::Beforeunload => "beforeunload".into(),
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let cut: String = s.chars().take(max).collect();
format!("{cut}…")
}
}
pub fn append_line(base: String, events: Option<String>) -> String {
match events {
Some(line) => format!("{base}\n\n{line}"),
None => base,
}
}
pub fn attach_page_listeners(page: &chromiumoxide::Page, log: EventLog) {
use chromiumoxide::cdp::browser_protocol as bp;
spawn_stream::<bp::page::EventJavascriptDialogOpening>(page, log.clone());
spawn_stream::<bp::browser::EventDownloadWillBegin>(page, log.clone());
spawn_stream::<bp::target::EventTargetCreated>(page, log.clone());
spawn_stream::<bp::target::EventTargetCrashed>(page, log.clone());
spawn_stream::<bp::inspector::EventDetached>(page, log);
}
fn spawn_stream<T>(page: &chromiumoxide::Page, log: EventLog)
where
T: chromiumoxide::cdp::IntoEventKind + Into<CdpEvent> + Clone + Send + Unpin + 'static,
{
let page = page.clone();
tokio::spawn(async move {
let mut stream = match page.event_listener::<T>().await {
Ok(s) => s,
Err(e) => {
tracing::debug!(
"browser: event subscribe failed for {}: {e}",
std::any::type_name::<T>()
);
return;
}
};
while let Some(event) = stream.next().await {
let cdp: CdpEvent = (*event).clone().into();
if let Some(summary) = map_cdp_event(&cdp) {
log.push(summary);
}
}
});
}