use lazyboy_contracts::{ComputerAction, PointerButton, PointerType, ScrollDirection}; use crate::screen::{normalize_display, PRIMARY_DISPLAY}; pub const DISPLAY: &str = PRIMARY_DISPLAY; pub const HOME: &str = "/home/lazyboy"; fn display_env(display: &str) -> String { format!("DISPLAY={}", normalize_display(display)) } pub fn xdotool_argv(action: &ComputerAction) -> Option> { xdotool_argv_on(PRIMARY_DISPLAY, action) } pub fn xdotool_argv_on(display: &str, action: &ComputerAction) -> Option> { let mut argv = vec!["env".into(), display_env(display), "xdotool".into()]; match action { ComputerAction::Pointer { x, y, pointer_type, button, } => { let button_n = match button.unwrap_or(PointerButton::Left) { PointerButton::Left => "1", PointerButton::Middle => "2", PointerButton::Right => "3", }; match pointer_type { PointerType::Move => { argv.extend([ "mousemove".into(), "--sync".into(), "--".into(), x.to_string(), y.to_string(), ]); } PointerType::Click => { argv.extend([ "mousemove".into(), "--sync".into(), "--".into(), x.to_string(), y.to_string(), "click".into(), "--delay".into(), "40".into(), button_n.into(), ]); } PointerType::Down => { argv.extend([ "mousemove".into(), "--".into(), x.to_string(), y.to_string(), "mousedown".into(), button_n.into(), ]); } PointerType::Up => { argv.extend([ "mousemove".into(), "--".into(), x.to_string(), y.to_string(), "mouseup".into(), button_n.into(), ]); } } } ComputerAction::Key { key, modifiers } => { let combo = match modifiers { Some(items) if !items.is_empty() => format!("{}+{key}", items.join("+")), _ => key.clone(), }; argv.extend(["key".into(), "--clearmodifiers".into(), combo]); } ComputerAction::Clipboard { text } => { let quoted = shell_single_quote(text); if looks_like_typed_ascii(text) { argv.extend(["type".into(), "--delay".into(), "16".into(), "--".into(), text.clone()]); } else { return Some(vec![ "env".into(), display_env(display), "bash".into(), "-lc".into(), format!( "printf %s {quoted} | xclip -selection clipboard && xdotool key --clearmodifiers ctrl+v" ), ]); } } ComputerAction::Scroll { direction, amount } => { let button = match direction { ScrollDirection::Up => "4", ScrollDirection::Down => "5", }; argv.extend([ "click".into(), "--repeat".into(), amount.unwrap_or(12).to_string(), "--delay".into(), "15".into(), button.into(), ]); } ComputerAction::Focus { title } => { let quoted = shell_single_quote(title); return Some(vec![ "env".into(), display_env(display), "bash".into(), "-lc".into(), format!( "wmctrl -a {quoted} || xdotool search --name {quoted} windowactivate --sync windowfocus" ), ]); } ComputerAction::Wait { .. } | ComputerAction::Open { .. } | ComputerAction::Launch { .. } => { return None; } } Some(argv) } fn shell_single_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\\''")) } fn looks_like_typed_ascii(text: &str) -> bool { text.len() <= 48 && text.chars().all(|ch| ch.is_ascii() && (!ch.is_control() || ch == '\n' || ch == '\t')) } pub fn action_pause_ms(action: &ComputerAction) -> u64 { match action { ComputerAction::Pointer { pointer_type, .. } => match pointer_type { PointerType::Move => 18, PointerType::Click => 55, PointerType::Down | PointerType::Up => 30, }, ComputerAction::Scroll { .. } => 45, ComputerAction::Clipboard { text } if looks_like_typed_ascii(text) => 40, ComputerAction::Clipboard { .. } | ComputerAction::Key { .. } => 35, ComputerAction::Focus { .. } => 90, ComputerAction::Open { .. } | ComputerAction::Launch { .. } => 220, ComputerAction::Wait { .. } => 0, } } pub fn pointer_state_command() -> Vec { pointer_state_command_on(PRIMARY_DISPLAY) } pub fn pointer_state_command_on(display: &str) -> Vec { vec![ "env".into(), display_env(display), "python3".into(), "-c".into(), r#" import json, subprocess def out(args): try: return subprocess.check_output(args, stderr=subprocess.DEVNULL, text=True).strip() except Exception: return "" vals = {} for line in out(["xdotool", "getmouselocation", "--shell"]).splitlines(): if "=" in line: key, value = line.split("=", 1) vals[key] = value wid = out(["xdotool", "getactivewindow"]) title = out(["xdotool", "getwindowname", wid]) if wid else "" print(json.dumps({"x": int(vals.get("X") or 0), "y": int(vals.get("Y") or 0), "id": wid, "title": title})) "# .into(), ] } pub fn parse_pointer_state(raw: &str) -> (Option, Option) { let value: serde_json::Value = serde_json::from_str(raw.trim()).unwrap_or(serde_json::Value::Null); let cursor = match (value.get("x").and_then(serde_json::Value::as_i64), value.get("y").and_then(serde_json::Value::as_i64)) { (Some(x), Some(y)) => Some(lazyboy_contracts::CursorPosition { x: x as i32, y: y as i32, }), _ => None, }; let window = value.get("id").and_then(serde_json::Value::as_str).filter(|id| !id.is_empty()).map(|id| { lazyboy_contracts::ActiveWindow { id: id.to_string(), title: value .get("title") .and_then(serde_json::Value::as_str) .filter(|title| !title.is_empty()) .map(str::to_string), } }); (cursor, window) } pub fn open_argv(path: &str) -> Vec { open_argv_on(PRIMARY_DISPLAY, None, path) } pub fn open_argv_on(display: &str, profile: Option<&str>, path: &str) -> Vec { if path.starts_with("http://") || path.starts_with("https://") { return browser_argv(display, profile, Some(path)); } vec![ "env".into(), display_env(display), "xdg-open".into(), path.into(), ] } pub fn launch_argv(application: &str, uri: Option<&str>) -> Option> { launch_argv_on(PRIMARY_DISPLAY, None, application, uri) } pub fn launch_argv_on( display: &str, profile: Option<&str>, application: &str, uri: Option<&str>, ) -> Option> { match application { "browser" | "chrome" | "chromium" | "lazyboy-browser" => { Some(browser_argv(display, profile, uri)) } "xterm" | "terminal" | "xfce4-terminal" | "lazyboy-terminal" => { let mut argv = vec!["env".into(), display_env(display), "lazyboy-terminal".into()]; if let Some(uri) = uri { argv.push(uri.into()); } Some(argv) } _ => None, } } fn browser_argv(display: &str, profile: Option<&str>, uri: Option<&str>) -> Vec { let mut argv = vec!["env".into(), display_env(display)]; if let Some(profile) = profile.filter(|value| !value.is_empty()) { argv.push(format!("LAZYBOY_BROWSER_PROFILE={profile}")); } argv.push("lazyboy-browser".into()); if let Some(uri) = uri { argv.push(uri.into()); } argv } pub fn screenshot_command() -> Vec { screenshot_command_on(PRIMARY_DISPLAY) } pub fn screenshot_command_on(display: &str) -> Vec { vec![ "bash".into(), "-lc".into(), format!( "DISPLAY={} xwd -root -silent | convert xwd:- png:-", normalize_display(display) ), ] } #[cfg(test)] mod tests { use super::*; #[test] fn click_maps_to_xdotool() { let argv = xdotool_argv(&ComputerAction::Pointer { x: 12, y: 40, pointer_type: PointerType::Click, button: Some(PointerButton::Left), }) .unwrap(); assert!(argv.contains(&"click".into())); assert!(argv.contains(&"12".into())); } #[test] fn pointer_up_moves_before_release() { let argv = xdotool_argv(&ComputerAction::Pointer { x: 80, y: 90, pointer_type: PointerType::Up, button: Some(PointerButton::Left), }) .unwrap(); assert!(argv.contains(&"mousemove".into())); assert!(argv.contains(&"80".into())); assert!(argv.contains(&"mouseup".into())); } #[test] fn extra_display_is_injected_into_input_commands() { let argv = xdotool_argv_on( ":2", &ComputerAction::Pointer { x: 4, y: 8, pointer_type: PointerType::Move, button: None, }, ) .unwrap(); assert!(argv.contains(&"DISPLAY=:2".into())); let shot = screenshot_command_on(":3"); assert!(shot.last().unwrap().contains("DISPLAY=:3")); let browser = launch_argv_on(":2", Some("/home/lazyboy/.browser-profiles/bots/a"), "browser", None) .unwrap(); assert!(browser.contains(&"DISPLAY=:2".into())); assert!(browser.iter().any(|item| item.contains("bots/a"))); } }