lazyBoy/crates/control/src/cdp.rs

272 lines
8.5 KiB
Rust
Raw Normal View History

2026-09-04 09:08:56 +00:00
use lazyboy_contracts::UiElement;
2026-09-07 14:23:38 +00:00
use serde::{Deserialize, Serialize};
2026-09-04 09:08:56 +00:00
use serde_json::{Value, json};
2026-09-08 01:14:53 +00:00
use crate::normalize_display;
2026-09-04 09:08:56 +00:00
use crate::x11::parse_ui_elements;
const CDP_PY: &str = include_str!("cdp.py");
pub fn devtools_port(display: &str) -> u16 {
let number = normalize_display(display)
.trim_start_matches(':')
.parse::<u16>()
.unwrap_or(1)
.max(1);
9221 + number
}
pub fn cdp_command_on(display: &str, profile: Option<&str>, request: &Value) -> Vec<String> {
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(),
]
}
/// Same as `cdp_command_on` but the JSON body is meant to arrive on stdin
/// so secrets never appear on the process argv.
pub fn cdp_stdin_command_on(display: &str, profile: Option<&str>) -> Vec<String> {
let mut env = vec![
"env".into(),
format!("DISPLAY={}", normalize_display(display)),
];
if let Some(profile) = profile.filter(|value| !value.is_empty()) {
env.push(format!("LAZYBOY_BROWSER_PROFILE={profile}"));
}
2026-09-07 14:23:38 +00:00
env.extend(["python3".into(), "-c".into(), CDP_PY.into()]);
env
}
2026-09-04 14:43:29 +00:00
/// Marker embedded in the recorder's argv so `pkill -f` can find exactly one
/// teaching session without touching other python processes.
pub fn teach_recorder_tag(skill_id: &str) -> String {
format!("lazyboy-teach-{skill_id}")
}
pub fn teach_recorder_output(skill_id: &str) -> String {
format!("/tmp/{}.jsonl", teach_recorder_tag(skill_id))
}
2026-09-07 14:23:38 +00:00
pub fn sanitize_skill_id(skill_id: &str) -> String {
let cleaned: String = skill_id
.chars()
.filter(|ch| ch.is_ascii_alphanumeric() || *ch == '-' || *ch == '_')
.take(80)
.collect();
if cleaned.is_empty() {
"unknown".into()
} else {
cleaned
}
}
pub fn teach_trajectory_dir(skill_id: &str) -> String {
format!("/tmp/lazyboy/teach-{}", sanitize_skill_id(skill_id))
}
2026-09-04 14:43:29 +00:00
/// Detached, long-running CDP recorder for a human demonstration. The script
/// is handed to `sh` as a positional argument so no shell quoting touches it.
pub fn cdp_record_command_on(display: &str, profile: Option<&str>, skill_id: &str) -> Vec<String> {
let mut request = json!({
"action": "record",
"ensure": true,
"out": teach_recorder_output(skill_id),
"tag": teach_recorder_tag(skill_id),
"display": normalize_display(display),
"port": devtools_port(display),
});
if let Some(profile) = profile.filter(|value| !value.is_empty()) {
request["profile"] = json!(profile);
}
vec![
"sh".into(),
"-c".into(),
2026-09-07 14:23:38 +00:00
"setsid nohup env DISPLAY=\"$0\" python3 -c \"$1\" \"$2\" >/dev/null 2>&1 </dev/null &"
.into(),
2026-09-04 14:43:29 +00:00
normalize_display(display).to_string(),
CDP_PY.into(),
request.to_string(),
]
}
pub fn cdp_record_stop_command(skill_id: &str) -> Vec<String> {
vec!["pkill".into(), "-f".into(), teach_recorder_tag(skill_id)]
}
2026-09-07 14:23:38 +00:00
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
2026-09-04 09:08:56 +00:00
pub struct CdpPage {
pub ok: bool,
2026-09-07 14:23:38 +00:00
#[serde(default, skip_serializing_if = "Option::is_none")]
2026-09-04 09:08:56 +00:00
pub error: Option<String>,
2026-09-07 14:23:38 +00:00
#[serde(default)]
2026-09-04 09:08:56 +00:00
pub url: String,
2026-09-07 14:23:38 +00:00
#[serde(default)]
2026-09-04 09:08:56 +00:00
pub title: String,
2026-09-07 14:23:38 +00:00
#[serde(default)]
2026-09-04 09:08:56 +00:00
pub text: String,
2026-09-07 14:23:38 +00:00
#[serde(default)]
2026-09-04 09:08:56 +00:00
pub restarted: bool,
2026-09-04 15:59:50 +00:00
/// Seconds the click waited for a disabled control to become enabled.
2026-09-07 14:23:38 +00:00
#[serde(default, skip_serializing_if = "Option::is_none")]
2026-09-04 15:59:50 +00:00
pub waited_seconds: Option<f64>,
2026-09-07 14:23:38 +00:00
#[serde(default)]
2026-09-04 09:08:56 +00:00
pub elements: Vec<UiElement>,
}
pub fn parse_cdp_page(raw: &str) -> CdpPage {
let value: Value = serde_json::from_str(raw.trim()).unwrap_or(Value::Null);
2026-09-04 15:59:50 +00:00
let ok = value.get("ok").and_then(Value::as_bool) == Some(true);
CdpPage {
ok,
error: if ok {
None
} else {
value
2026-09-04 09:08:56 +00:00
.get("error")
.and_then(Value::as_str)
.map(str::to_string)
2026-09-04 15:59:50 +00:00
.or_else(|| Some("cdp unavailable".into()))
},
waited_seconds: value.get("waitedSeconds").and_then(Value::as_f64),
2026-09-04 09:08:56 +00:00
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<UiElement>, page: &[UiElement]) -> Vec<UiElement> {
crate::merge_ui_elements(windows, page, &[])
2026-09-04 09:08:56 +00:00
}
#[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);
}
2026-09-07 14:23:38 +00:00
#[test]
fn trajectory_dir_strips_path_chars() {
assert_eq!(teach_trajectory_dir("abc/../x"), "/tmp/lazyboy/teach-abcx");
assert_eq!(sanitize_skill_id(""), "unknown");
}
2026-09-04 09:08:56 +00:00
#[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"));
}
}