use lazyboy_contracts::UiElement; use serde_json::{Value, json}; use crate::x11::parse_ui_elements; use crate::{PRIMARY_DISPLAY, normalize_display}; const CDP_PY: &str = include_str!("cdp.py"); pub fn devtools_port(display: &str) -> u16 { let number = normalize_display(display) .trim_start_matches(':') .parse::() .unwrap_or(1) .max(1); 9221 + number } pub fn cdp_command_on(display: &str, profile: Option<&str>, request: &Value) -> Vec { let mut body = request.clone(); if let Some(object) = body.as_object_mut() { object .entry("display") .or_insert_with(|| json!(normalize_display(display))); object .entry("port") .or_insert_with(|| json!(devtools_port(display))); if let Some(profile) = profile.filter(|value| !value.is_empty()) { object.entry("profile").or_insert_with(|| json!(profile)); } } vec![ "env".into(), format!("DISPLAY={}", normalize_display(display)), "python3".into(), "-c".into(), CDP_PY.into(), body.to_string(), ] } pub fn cdp_command(request: &Value) -> Vec { cdp_command_on(PRIMARY_DISPLAY, None, request) } #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct CdpPage { pub ok: bool, pub error: Option, pub url: String, pub title: String, pub text: String, pub restarted: bool, pub elements: Vec, } pub fn parse_cdp_page(raw: &str) -> CdpPage { let value: Value = serde_json::from_str(raw.trim()).unwrap_or(Value::Null); if value.get("ok").and_then(Value::as_bool) != Some(true) { return CdpPage { ok: false, error: value .get("error") .and_then(Value::as_str) .map(str::to_string) .or_else(|| Some("cdp unavailable".into())), ..CdpPage::default() }; } CdpPage { ok: true, error: None, url: value .get("url") .and_then(Value::as_str) .unwrap_or("") .to_string(), title: value .get("title") .and_then(Value::as_str) .unwrap_or("") .to_string(), text: value .get("text") .and_then(Value::as_str) .unwrap_or("") .to_string(), restarted: value.get("restarted").and_then(Value::as_bool) == Some(true), elements: value .get("elements") .map(|items| parse_ui_elements(&items.to_string())) .unwrap_or_default(), } } pub fn merge_page_elements(windows: Vec, page: &[UiElement]) -> Vec { if page.is_empty() { return windows; } let mut elements = page.to_vec(); let mut next = elements.len() as u32 + 1; for mut window in windows { let title = window.title.to_lowercase(); if title.contains("chromium") || title.contains("chrome") { continue; } window.id = next; next += 1; elements.push(window); } elements } #[cfg(test)] mod tests { use super::*; #[test] fn debug_port_follows_the_display() { assert_eq!(devtools_port(":1"), 9222); assert_eq!(devtools_port(":2"), 9223); assert_eq!(devtools_port("3"), 9224); } #[test] fn command_passes_port_profile_and_script() { let argv = cdp_command_on( ":2", Some("/home/lazyboy/.browser-profiles/bots/a"), &json!({"action": "snapshot", "ensure": true}), ); assert!(argv.contains(&"DISPLAY=:2".into())); assert!(argv.iter().any(|item| item.contains("python3"))); let payload = argv.last().unwrap(); assert!(payload.contains("\"port\":9223")); assert!(payload.contains("bots/a")); assert!(payload.contains("snapshot")); } #[test] fn parses_snapshot_elements() { let page = parse_cdp_page( r#"{"ok":true,"url":"https://example.com","title":"Example","text":"Hello","elements":[{"id":1,"title":"Submit","selector":"[data-lazyboy=\"1\"]","kind":"dom","x":10,"y":20,"w":80,"h":24}]}"#, ); assert!(page.ok); assert_eq!(page.url, "https://example.com"); assert_eq!(page.elements.len(), 1); assert_eq!(page.elements[0].title, "Submit"); assert_eq!( page.elements[0].selector.as_deref(), Some("[data-lazyboy=\"1\"]") ); assert_eq!(page.elements[0].center(), (50, 32)); } #[test] fn merge_keeps_page_controls_and_native_dialogs() { let windows = vec![ UiElement { id: 1, title: "Chromium".into(), x: 0, y: 0, w: 1280, h: 800, kind: Some("window".into()), ..UiElement::default() }, UiElement { id: 2, title: "Open File".into(), x: 100, y: 100, w: 400, h: 300, kind: Some("window".into()), ..UiElement::default() }, ]; let page = vec![UiElement { id: 1, title: "Login".into(), selector: Some("[data-lazyboy=\"1\"]".into()), kind: Some("dom".into()), x: 40, y: 80, w: 60, h: 20, ..UiElement::default() }]; let merged = merge_page_elements(windows, &page); assert_eq!(merged.len(), 2); assert_eq!(merged[0].title, "Login"); assert_eq!(merged[1].id, 2); assert_eq!(merged[1].title, "Open File"); } #[test] fn unavailable_page_is_not_ok() { let page = parse_cdp_page(r#"{"ok":false,"error":"cdp unavailable"}"#); assert!(!page.ok); assert_eq!(page.error.as_deref(), Some("cdp unavailable")); } }