1065 lines
46 KiB
Rust
1065 lines
46 KiB
Rust
|
|
// Screen control on 你的電腦 (computer use), like Grok Bot's Computer Use helper: the bot looks at
|
|||
|
|
// the screen and uses the mouse and keyboard. Same tool names and arguments as the original's
|
|||
|
|
// local daemon (`computer_use_screenshot`, `computer_use_click` {method, x, y, button, count,
|
|||
|
|
// modifier_keys}, `computer_use_scroll`, `computer_use_mouse_move`, `computer_use_drag`,
|
|||
|
|
// `computer_use_typing` {value}, `computer_use_press_key` {key, hold_duration_ms}).
|
|||
|
|
//
|
|||
|
|
// Screenshot sizing (local-exec-daemon main.cjs): `zct` fits the long edge to 1024 (Gct) keeping
|
|||
|
|
// both edges at least 8 (Hs); images stay under 1 MiB (mg = 1024*1024); JPEG is tried at quality
|
|||
|
|
// 85, 70, 55 ($3r), taking the first that fits, else the smallest.
|
|||
|
|
|
|||
|
|
use base64::Engine;
|
|||
|
|
use serde_json::{json, Value};
|
|||
|
|
use std::sync::Mutex;
|
|||
|
|
|
|||
|
|
pub const NAMES: [&str; 9] = ["computer_use_screenshot", "computer_use_click", "computer_use_typing", "computer_use_press_key", "computer_use_scroll", "computer_use_mouse_move", "computer_use_drag", "computer_use_mouse_button", "computer_use_check_permissions"];
|
|||
|
|
|
|||
|
|
/// The person took control back from the bot (the floating bar): screen actions wait.
|
|||
|
|
pub static HELD_BY_PERSON: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
|||
|
|
/// When the bot last moved the mouse or typed on this Mac (for the floating bar), and who.
|
|||
|
|
pub static LAST_ACTION: Mutex<Option<(std::time::Instant, String)>> = Mutex::new(None);
|
|||
|
|
/// Longest screenshot edge sent to the model (Gct).
|
|||
|
|
pub const MAX_LONG_EDGE: u32 = 1024;
|
|||
|
|
/// Shortest edge never goes below this (Hs).
|
|||
|
|
pub const MIN_EDGE: u32 = 8;
|
|||
|
|
/// Largest screenshot sent (mg = 1024*1024).
|
|||
|
|
pub const MAX_IMAGE_BYTES: usize = 1048576;
|
|||
|
|
/// JPEG qualities tried in order ($3r).
|
|||
|
|
pub const JPEG_QUALITIES: [u8; 3] = [85, 70, 55];
|
|||
|
|
/// Screenshots kept in what's sent to the model; older ones become a note (estimated).
|
|||
|
|
pub const KEEP_SCREENSHOTS: usize = 3;
|
|||
|
|
/// Most UTF-16 units per typed chunk; characters are never split (CUTypedCharacters.utf16Chunks
|
|||
|
|
/// @0x1000beb00: capacity 0x14, a new chunk when the next character would pass 20).
|
|||
|
|
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
|||
|
|
pub const TYPE_CHUNK_UNITS: usize = 20;
|
|||
|
|
/// Virtual key the typed text rides on (postChunks @0x1000beebc: CGEventCreateKeyboardEvent(_, 0x80, _)).
|
|||
|
|
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
|||
|
|
const TYPE_KEY_CODE: u16 = 128;
|
|||
|
|
/// Key down → key up for typed chunks and pressed keys (postChunks / handlePressKey: sleep 0.035 s).
|
|||
|
|
pub const KEY_HOLD_SECS: f64 = 0.035;
|
|||
|
|
/// Between typed chunks and between keys of one press (postChunks / handlePressKey: sleep 0.02 s).
|
|||
|
|
pub const KEY_GAP_SECS: f64 = 0.02;
|
|||
|
|
/// Mouse down → up when no hold is given (CUActuator.click @0x100010120: hold > 0 ? hold : 0.03).
|
|||
|
|
pub const CLICK_HOLD_SECS: f64 = 0.03;
|
|||
|
|
/// Between the clicks of a double/triple click (CUActuator.click: sleep 0.08 s).
|
|||
|
|
pub const CLICK_REPEAT_GAP_SECS: f64 = 0.08;
|
|||
|
|
/// Interpolated dragged events per path segment (handleDrag @0x10009dee4 passes steps: 6).
|
|||
|
|
pub const DRAG_STEPS: usize = 6;
|
|||
|
|
/// Hold after pressing, before moving (handleDrag @0x10009dee4 passes holdSeconds: 0.04).
|
|||
|
|
pub const DRAG_HOLD_SECS: f64 = 0.04;
|
|||
|
|
|
|||
|
|
/// The dragged-event points: each segment split into `DRAG_STEPS` equal parts, ending on its end
|
|||
|
|
/// point (CURemoteEventPoster.drag @0x10008c34c: start + delta * i / steps, i = 1…steps).
|
|||
|
|
pub fn drag_points(path: &[(f64, f64)]) -> Vec<(f64, f64)> {
|
|||
|
|
path.windows(2)
|
|||
|
|
.flat_map(|w| {
|
|||
|
|
let ((ax, ay), (bx, by)) = (w[0], w[1]);
|
|||
|
|
(1..=DRAG_STEPS).map(move |i| {
|
|||
|
|
let t = i as f64 / DRAG_STEPS as f64;
|
|||
|
|
(ax + (bx - ax) * t, ay + (by - ay) * t)
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
.collect()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Scroll steps when none is given, and the allowed range (handleScroll @0x10009e200: default 3,
|
|||
|
|
/// max(amount, 1), min(…, 20)).
|
|||
|
|
pub const SCROLL_DEFAULT_AMOUNT: i64 = 3;
|
|||
|
|
pub const SCROLL_MAX_AMOUNT: i64 = 20;
|
|||
|
|
/// Pixels per scroll step (handleScroll @0x10009e200: (amount + amount) << 3).
|
|||
|
|
pub const SCROLL_PIXELS_PER_STEP: i32 = 16;
|
|||
|
|
|
|||
|
|
pub fn scroll_amount(amount: Option<i64>) -> i32 {
|
|||
|
|
amount.unwrap_or(SCROLL_DEFAULT_AMOUNT).clamp(1, SCROLL_MAX_AMOUNT) as i32
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// (vertical, horizontal) pixel deltas: up and left positive, down and right negative.
|
|||
|
|
pub fn scroll_pixels(direction: &str, amount: i32) -> Option<(i32, i32)> {
|
|||
|
|
let px = amount * SCROLL_PIXELS_PER_STEP;
|
|||
|
|
match direction {
|
|||
|
|
"up" => Some((px, 0)),
|
|||
|
|
"down" => Some((-px, 0)),
|
|||
|
|
"left" => Some((0, px)),
|
|||
|
|
"right" => Some((0, -px)),
|
|||
|
|
_ => None,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Pause between mouse move / drag steps (estimated).
|
|||
|
|
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
|||
|
|
const EVENT_GAP_MS: u64 = 8;
|
|||
|
|
/// Longest key hold allowed (estimated).
|
|||
|
|
const MAX_HOLD_MS: u64 = 5000;
|
|||
|
|
|
|||
|
|
/// utf16Chunks: split text into chunks of at most `TYPE_CHUNK_UNITS` UTF-16 units without
|
|||
|
|
/// splitting a character (a character longer than that gets its own chunk).
|
|||
|
|
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
|||
|
|
pub fn utf16_chunks(text: &str) -> Vec<Vec<u16>> {
|
|||
|
|
let mut out: Vec<Vec<u16>> = vec![];
|
|||
|
|
let mut cur: Vec<u16> = Vec::with_capacity(TYPE_CHUNK_UNITS);
|
|||
|
|
for c in text.chars() {
|
|||
|
|
let mut buf = [0u16; 2];
|
|||
|
|
let units = c.encode_utf16(&mut buf);
|
|||
|
|
if !cur.is_empty() && cur.len() + units.len() > TYPE_CHUNK_UNITS {
|
|||
|
|
out.push(std::mem::take(&mut cur));
|
|||
|
|
}
|
|||
|
|
cur.extend_from_slice(units);
|
|||
|
|
}
|
|||
|
|
if !cur.is_empty() {
|
|||
|
|
out.push(cur);
|
|||
|
|
}
|
|||
|
|
out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// "cmd+a delete" → each key of one press, in order (handlePressKey presses a list of keys).
|
|||
|
|
pub fn parse_keys(keys: &str) -> Result<Vec<(u16, u64)>, String> {
|
|||
|
|
let list: Vec<&str> = keys.split_whitespace().collect();
|
|||
|
|
if list.is_empty() {
|
|||
|
|
return Err("請給要按的鍵".into());
|
|||
|
|
}
|
|||
|
|
list.into_iter().map(parse_combo).collect()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Screen points per screenshot pixel, from the last screenshot (x, y).
|
|||
|
|
static SCALE: Mutex<Option<(f64, f64)>> = Mutex::new(None);
|
|||
|
|
|
|||
|
|
pub fn is_computer_tool(name: &str) -> bool {
|
|||
|
|
NAMES.contains(&name)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn label(name: &str) -> &'static str {
|
|||
|
|
match name {
|
|||
|
|
"computer_use_screenshot" => "看你的螢幕",
|
|||
|
|
"computer_use_click" => "點一下",
|
|||
|
|
"computer_use_typing" => "打字",
|
|||
|
|
"computer_use_press_key" => "按鍵",
|
|||
|
|
"computer_use_scroll" => "捲動",
|
|||
|
|
"computer_use_mouse_move" => "移動滑鼠",
|
|||
|
|
"computer_use_drag" => "拖曳",
|
|||
|
|
"computer_use_mouse_button" => "按住或放開滑鼠",
|
|||
|
|
"computer_use_check_permissions" => "檢查螢幕權限",
|
|||
|
|
_ => "操作畫面",
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn args_of(args: &str) -> Value {
|
|||
|
|
serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(Value::Null)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn describe(name: &str, args: &str) -> String {
|
|||
|
|
let v = args_of(args);
|
|||
|
|
let xy = || format!("({}, {})", v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0));
|
|||
|
|
match name {
|
|||
|
|
"computer_use_screenshot" => "螢幕截圖".into(),
|
|||
|
|
"computer_use_click" => {
|
|||
|
|
let b = v["button"].as_str().unwrap_or("left");
|
|||
|
|
let n = v["count"].as_u64().unwrap_or(1);
|
|||
|
|
format!("{}{}{} {}", if b == "right" { "右鍵" } else { "" }, if n >= 2 { "連點" } else { "點" }, if n >= 2 { format!(" {n} 下") } else { String::new() }, xy())
|
|||
|
|
}
|
|||
|
|
"computer_use_typing" => {
|
|||
|
|
let t = v["value"].as_str().unwrap_or("");
|
|||
|
|
let short: String = t.chars().take(60).collect();
|
|||
|
|
format!("「{short}{}」", if t.chars().count() > 60 { "…" } else { "" })
|
|||
|
|
}
|
|||
|
|
"computer_use_press_key" => v["key"].as_str().unwrap_or("").to_string(),
|
|||
|
|
"computer_use_scroll" => format!("{} {} 格,在 {}", v["direction"].as_str().unwrap_or("down"), v["amount"].as_i64().unwrap_or(3), xy()),
|
|||
|
|
"computer_use_mouse_move" => xy(),
|
|||
|
|
"computer_use_drag" => format!("{} 個點", v["path"].as_array().map(|a| a.len()).unwrap_or(0)),
|
|||
|
|
"computer_use_mouse_button" => format!("{}{}", if v["action"].as_str() == Some("release") { "放開" } else { "按住" }, if v["x"].is_null() { String::new() } else { format!(" {}", xy()) }),
|
|||
|
|
"computer_use_check_permissions" => "螢幕錄製、輔助使用".into(),
|
|||
|
|
_ => String::new(),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[allow(dead_code)]
|
|||
|
|
pub fn specs() -> Vec<Value> {
|
|||
|
|
specs_for(&[crate::model::THIS_COMPUTER_NAME.to_string()], crate::model::THIS_COMPUTER_NAME)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// The screen tools, with a `computer` choice when the bot has its own desktop too.
|
|||
|
|
pub fn specs_for(computers: &[String], default: &str) -> Vec<Value> {
|
|||
|
|
let mods = json!({"type": "array", "items": {"type": "string", "enum": ["cmd", "shift", "option", "control"]}});
|
|||
|
|
let mut v = vec![
|
|||
|
|
json!({"name": "computer_use_screenshot", "description": "Take a screenshot of a computer's screen to see what's on it. Coordinates for the other computer_use tools are pixels of the latest screenshot of that computer. Take one before clicking, and again to check the result. Needs a model that can see images.",
|
|||
|
|
"parameters": {"type": "object", "properties": {}}}),
|
|||
|
|
json!({"name": "computer_use_click", "description": "Click on the screen at x, y (pixels of the latest screenshot). background=true clicks a window on the person's Mac without bringing it to the front.",
|
|||
|
|
"parameters": {"type": "object", "properties": {"x": {"type": "number"}, "y": {"type": "number"}, "button": {"type": "string", "enum": ["left", "right", "middle"]}, "count": {"type": "integer", "description": "2 for a double click"}, "modifier_keys": mods, "background": {"type": "boolean"}}, "required": ["x", "y"]}}),
|
|||
|
|
json!({"name": "computer_use_typing", "description": "Type text where the cursor is (any language).",
|
|||
|
|
"parameters": {"type": "object", "properties": {"value": {"type": "string"}}, "required": ["value"]}}),
|
|||
|
|
json!({"name": "computer_use_press_key", "description": "Press a key or shortcut, e.g. return, tab, escape, delete, up, cmd+c, cmd+shift+t, cmd+space. Several keys in order: separate with spaces, e.g. \"cmd+a delete\". On the bot's Linux computer cmd means ctrl.",
|
|||
|
|
"parameters": {"type": "object", "properties": {"key": {"type": "string"}, "hold_duration_ms": {"type": "integer"}}, "required": ["key"]}}),
|
|||
|
|
json!({"name": "computer_use_scroll", "description": "Scroll at x, y (give both or neither; neither = where the pointer is).",
|
|||
|
|
"parameters": {"type": "object", "properties": {"x": {"type": "number"}, "y": {"type": "number"}, "direction": {"type": "string", "enum": ["up", "down", "left", "right"]}, "amount": {"type": "integer", "description": "Scroll steps of 16 pixels, 1-20, default 3"}, "modifier_keys": mods}, "required": ["direction"]}}),
|
|||
|
|
json!({"name": "computer_use_mouse_move", "description": "Move the mouse to x, y (e.g. to show a hover menu).",
|
|||
|
|
"parameters": {"type": "object", "properties": {"x": {"type": "number"}, "y": {"type": "number"}}, "required": ["x", "y"]}}),
|
|||
|
|
json!({"name": "computer_use_drag", "description": "Drag with the mouse along a path of points (first = press, last = release).",
|
|||
|
|
"parameters": {"type": "object", "properties": {"path": {"type": "array", "items": {"type": "object", "properties": {"x": {"type": "number"}, "y": {"type": "number"}}, "required": ["x", "y"]}}, "button": {"type": "string", "enum": ["left", "right"]}, "modifier_keys": mods}, "required": ["path"]}}),
|
|||
|
|
json!({"name": "computer_use_mouse_button", "description": "Press and hold, or release, a mouse button (optionally moving to x, y first). For long presses and custom drags.",
|
|||
|
|
"parameters": {"type": "object", "properties": {"action": {"type": "string", "enum": ["press", "release"]}, "button": {"type": "string", "enum": ["left", "right", "middle"]}, "x": {"type": "number"}, "y": {"type": "number"}}, "required": ["action"]}}),
|
|||
|
|
json!({"name": "computer_use_check_permissions", "description": "Check whether the screen can be seen and the mouse and keyboard used on that computer.",
|
|||
|
|
"parameters": {"type": "object", "properties": {}}}),
|
|||
|
|
];
|
|||
|
|
if computers.len() > 1 {
|
|||
|
|
for t in v.iter_mut() {
|
|||
|
|
t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": computers,
|
|||
|
|
"description": format!("Whose screen. Default: {default}. {} is the bot's own Linux desktop; {} is the person's Mac (use it for the person's own apps).", crate::model::BOT_COMPUTER_NAME, crate::model::THIS_COMPUTER_NAME)});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
v
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// zct: the size to send. Long edge down to `MAX_LONG_EDGE`, never an edge under `MIN_EDGE`.
|
|||
|
|
pub fn fit(w: u32, h: u32) -> (u32, u32, bool) {
|
|||
|
|
if w < 1 || h < 1 {
|
|||
|
|
return (w, h, false);
|
|||
|
|
}
|
|||
|
|
let long = w.max(h);
|
|||
|
|
if long <= MAX_LONG_EDGE {
|
|||
|
|
return (w, h, false);
|
|||
|
|
}
|
|||
|
|
let short = w.min(h);
|
|||
|
|
if short < MIN_EDGE {
|
|||
|
|
return (w, h, false);
|
|||
|
|
}
|
|||
|
|
let mut i = MAX_LONG_EDGE as f64 / long as f64;
|
|||
|
|
let mut o = ((w as f64 * i).round() as u32).max(1);
|
|||
|
|
let mut a = ((h as f64 * i).round() as u32).max(1);
|
|||
|
|
if o < MIN_EDGE || a < MIN_EDGE {
|
|||
|
|
i = MIN_EDGE as f64 / short as f64;
|
|||
|
|
o = ((w as f64 * i).round() as u32).max(MIN_EDGE);
|
|||
|
|
a = ((h as f64 * i).round() as u32).max(MIN_EDGE);
|
|||
|
|
}
|
|||
|
|
(o, a, o != w || a != h)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// j3r: JPEG at 85, 70, 55: the first within the budget, else the smallest if it's under the cap.
|
|||
|
|
pub fn encode_jpeg(img: &image::RgbImage) -> Option<Vec<u8>> {
|
|||
|
|
let mut best: Option<Vec<u8>> = None;
|
|||
|
|
for q in JPEG_QUALITIES {
|
|||
|
|
let mut out = vec![];
|
|||
|
|
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, q);
|
|||
|
|
if img.write_with_encoder(enc).is_err() {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
if out.len() <= MAX_IMAGE_BYTES {
|
|||
|
|
return Some(out);
|
|||
|
|
}
|
|||
|
|
if best.as_ref().map_or(true, |b| out.len() < b.len()) {
|
|||
|
|
best = Some(out);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
best.filter(|b| b.len() <= MAX_IMAGE_BYTES)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Screenshot pixel → screen point.
|
|||
|
|
pub fn to_screen(x: f64, y: f64, scale: (f64, f64)) -> (f64, f64) {
|
|||
|
|
(x * scale.0, y * scale.1)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Key name → (key code, needs shift). US layout codes.
|
|||
|
|
pub fn key_code(name: &str) -> Option<u16> {
|
|||
|
|
let n = name.trim().to_lowercase();
|
|||
|
|
let named = match n.as_str() {
|
|||
|
|
"return" | "enter" => 36,
|
|||
|
|
"tab" => 48,
|
|||
|
|
"space" => 49,
|
|||
|
|
"delete" | "backspace" => 51,
|
|||
|
|
"escape" | "esc" => 53,
|
|||
|
|
"forwarddelete" | "forward_delete" | "del" => 117,
|
|||
|
|
"home" => 115,
|
|||
|
|
"end" => 119,
|
|||
|
|
"pageup" | "page_up" => 116,
|
|||
|
|
"pagedown" | "page_down" => 121,
|
|||
|
|
"left" | "arrowleft" => 123,
|
|||
|
|
"right" | "arrowright" => 124,
|
|||
|
|
"down" | "arrowdown" => 125,
|
|||
|
|
"up" | "arrowup" => 126,
|
|||
|
|
"f1" => 122,
|
|||
|
|
"f2" => 120,
|
|||
|
|
"f3" => 99,
|
|||
|
|
"f4" => 118,
|
|||
|
|
"f5" => 96,
|
|||
|
|
"f6" => 97,
|
|||
|
|
"f7" => 98,
|
|||
|
|
"f8" => 100,
|
|||
|
|
"f9" => 101,
|
|||
|
|
"f10" => 109,
|
|||
|
|
"f11" => 103,
|
|||
|
|
"f12" => 111,
|
|||
|
|
_ => u16::MAX,
|
|||
|
|
};
|
|||
|
|
if named != u16::MAX {
|
|||
|
|
return Some(named);
|
|||
|
|
}
|
|||
|
|
let mut cs = n.chars();
|
|||
|
|
let (Some(c), None) = (cs.next(), cs.next()) else { return None };
|
|||
|
|
const LETTERS: [u16; 26] = [0, 11, 8, 2, 14, 3, 5, 4, 34, 38, 40, 37, 46, 45, 31, 35, 12, 15, 1, 17, 32, 9, 13, 7, 16, 6];
|
|||
|
|
const DIGITS: [u16; 10] = [29, 18, 19, 20, 21, 23, 22, 26, 28, 25];
|
|||
|
|
match c {
|
|||
|
|
'a'..='z' => Some(LETTERS[(c as u8 - b'a') as usize]),
|
|||
|
|
'0'..='9' => Some(DIGITS[(c as u8 - b'0') as usize]),
|
|||
|
|
'-' => Some(27),
|
|||
|
|
'=' => Some(24),
|
|||
|
|
'[' => Some(33),
|
|||
|
|
']' => Some(30),
|
|||
|
|
';' => Some(41),
|
|||
|
|
'\'' => Some(39),
|
|||
|
|
',' => Some(43),
|
|||
|
|
'.' => Some(47),
|
|||
|
|
'/' => Some(44),
|
|||
|
|
'\\' => Some(42),
|
|||
|
|
'`' => Some(50),
|
|||
|
|
_ => None,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub const FLAG_SHIFT: u64 = 1 << 17;
|
|||
|
|
pub const FLAG_CONTROL: u64 = 1 << 18;
|
|||
|
|
pub const FLAG_OPTION: u64 = 1 << 19;
|
|||
|
|
pub const FLAG_CMD: u64 = 1 << 20;
|
|||
|
|
|
|||
|
|
pub fn modifier_flag(m: &str) -> Option<u64> {
|
|||
|
|
match m.trim().to_lowercase().as_str() {
|
|||
|
|
"cmd" | "command" | "meta" | "super" | "⌘" => Some(FLAG_CMD),
|
|||
|
|
"shift" => Some(FLAG_SHIFT),
|
|||
|
|
"option" | "alt" | "opt" => Some(FLAG_OPTION),
|
|||
|
|
"control" | "ctrl" => Some(FLAG_CONTROL),
|
|||
|
|
_ => None,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// "cmd+shift+t" → (key code, flags).
|
|||
|
|
pub fn parse_combo(combo: &str) -> Result<(u16, u64), String> {
|
|||
|
|
let parts: Vec<&str> = combo.split('+').map(str::trim).filter(|p| !p.is_empty()).collect();
|
|||
|
|
let Some((key, mods)) = parts.split_last() else { return Err("請給要按的鍵".into()) };
|
|||
|
|
let mut flags = 0;
|
|||
|
|
for m in mods {
|
|||
|
|
flags |= modifier_flag(m).ok_or_else(|| format!("不認得「{m}」這個修飾鍵"))?;
|
|||
|
|
}
|
|||
|
|
let code = key_code(key).ok_or_else(|| format!("不認得「{key}」這個鍵"))?;
|
|||
|
|
Ok((code, flags))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn flags_of(v: &Value) -> u64 {
|
|||
|
|
v["modifier_keys"].as_array().map(|a| a.iter().filter_map(|m| m.as_str().and_then(modifier_flag)).fold(0, |f, x| f | x)).unwrap_or(0)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Run a computer_use tool. Returns (text, ok, screenshot as base64 JPEG).
|
|||
|
|
#[allow(dead_code)]
|
|||
|
|
pub fn run(name: &str, args: &str) -> (String, bool, Option<String>) {
|
|||
|
|
run_by(name, args, "")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Same, remembering which bot acted (for the 「正在操作你的電腦」 bar).
|
|||
|
|
pub fn run_by(name: &str, args: &str, who: &str) -> (String, bool, Option<String>) {
|
|||
|
|
if name != "computer_use_screenshot" && name != "computer_use_check_permissions" {
|
|||
|
|
*LAST_ACTION.lock().unwrap() = Some((std::time::Instant::now(), who.to_string()));
|
|||
|
|
}
|
|||
|
|
let r = run_inner(name, &args_of(args));
|
|||
|
|
match r {
|
|||
|
|
Ok((t, img)) => (t, true, img),
|
|||
|
|
Err(e) => (e, false, None),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn run_inner(name: &str, v: &Value) -> Result<(String, Option<String>), String> {
|
|||
|
|
if !cfg!(any(target_os = "macos", target_os = "linux")) {
|
|||
|
|
return Err("操作畫面只支援 macOS 和 Linux".into());
|
|||
|
|
}
|
|||
|
|
#[cfg(target_os = "linux")]
|
|||
|
|
if !crate::desk::available() {
|
|||
|
|
return Err(crate::desk::describe().into());
|
|||
|
|
}
|
|||
|
|
if name == "computer_use_screenshot" {
|
|||
|
|
return screenshot().map(|(t, img)| (t, Some(img)));
|
|||
|
|
}
|
|||
|
|
#[cfg(target_os = "linux")]
|
|||
|
|
if name == "computer_use_check_permissions" {
|
|||
|
|
return Ok((crate::desk::describe().into(), None));
|
|||
|
|
}
|
|||
|
|
if name == "computer_use_check_permissions" {
|
|||
|
|
let see = mac::screen_capture_preflight();
|
|||
|
|
let control = crate::axread::trusted(false);
|
|||
|
|
return Ok((format!("螢幕錄製:{}\n輔助使用(滑鼠和鍵盤):{}{}", if see { "已允許" } else { "還沒允許" }, if control { "已允許" } else { "還沒允許" }, if see && control { "" } else { "\n請使用者到「系統設定 → 隱私權與安全性」打開 AnyBot(或「終端機」)。" }), None));
|
|||
|
|
}
|
|||
|
|
if HELD_BY_PERSON.load(std::sync::atomic::Ordering::SeqCst) {
|
|||
|
|
return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作;可以先截圖看看。".into());
|
|||
|
|
}
|
|||
|
|
if cfg!(target_os = "macos") && !crate::axread::trusted(true) {
|
|||
|
|
let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility").spawn();
|
|||
|
|
return Err("macOS 還沒讓 AnyBot 使用「輔助使用」,所以不能動滑鼠和鍵盤。我已經打開「系統設定 → 隱私權與安全性 → 輔助使用」:把 AnyBot(或「終端機」)打開,再叫我試一次。只要開一次。".into());
|
|||
|
|
}
|
|||
|
|
let scale = || SCALE.lock().unwrap().ok_or_else(|| "請先用 computer_use_screenshot 看一下畫面,座標要用截圖上的位置。".to_string());
|
|||
|
|
let pt = |v: &Value, s: (f64, f64)| -> Result<(f64, f64), String> {
|
|||
|
|
let (Some(x), Some(y)) = (v["x"].as_f64(), v["y"].as_f64()) else { return Err("請給 x 和 y".into()) };
|
|||
|
|
Ok(to_screen(x, y, s))
|
|||
|
|
};
|
|||
|
|
match name {
|
|||
|
|
"computer_use_click" => {
|
|||
|
|
let (x, y) = pt(v, scale()?)?;
|
|||
|
|
let button = v["button"].as_str().unwrap_or("left");
|
|||
|
|
let count = v["count"].as_u64().unwrap_or(1).clamp(1, 3) as i64;
|
|||
|
|
if v["background"].as_bool().unwrap_or(false) {
|
|||
|
|
let (app, pid) = mac::window_at(x, y).ok_or("那個位置沒有視窗")?;
|
|||
|
|
mac::click_in_background(pid, x, y, button, count, flags_of(v));
|
|||
|
|
return Ok((format!("已在背景點「{app}」的 ({}, {}),沒有把它拉到前面。要確認結果可以再截圖。", v["x"], v["y"]), None));
|
|||
|
|
}
|
|||
|
|
mac::click(x, y, button, count, flags_of(v));
|
|||
|
|
Ok((format!("已{}。要確認結果可以再截圖。", describe(name, &v.to_string())), None))
|
|||
|
|
}
|
|||
|
|
"computer_use_mouse_move" => {
|
|||
|
|
let (x, y) = pt(v, scale()?)?;
|
|||
|
|
mac::move_to(x, y);
|
|||
|
|
Ok(("已移動滑鼠。".into(), None))
|
|||
|
|
}
|
|||
|
|
"computer_use_scroll" => {
|
|||
|
|
// CURemoteToolService.handleScroll @0x10009e200 / closure @0x10009e998.
|
|||
|
|
let (has_x, has_y) = (!v["x"].is_null(), !v["y"].is_null());
|
|||
|
|
if has_x != has_y {
|
|||
|
|
return Err("x 和 y 要一起給".into());
|
|||
|
|
}
|
|||
|
|
let at = if has_x { Some(pt(v, scale()?)?) } else { None };
|
|||
|
|
if !v["amount"].is_null() && v["amount"].as_i64().is_none() {
|
|||
|
|
return Err("amount 要是整數".into());
|
|||
|
|
}
|
|||
|
|
let amount = scroll_amount(v["amount"].as_i64());
|
|||
|
|
let dir = v["direction"].as_str().unwrap_or("").trim().to_lowercase();
|
|||
|
|
let (dy, dx) = scroll_pixels(&dir, amount).ok_or_else(|| format!("方向「{dir}」要是 up、down、left 或 right"))?;
|
|||
|
|
mac::scroll(at, dy, dx, flags_of(v));
|
|||
|
|
Ok((format!("已捲動 {dir} ×{amount}。"), None))
|
|||
|
|
}
|
|||
|
|
"computer_use_drag" => {
|
|||
|
|
let s = scale()?;
|
|||
|
|
let path: Vec<(f64, f64)> = v["path"].as_array().map(|a| a.iter().filter_map(|p| pt(p, s).ok()).collect()).unwrap_or_default();
|
|||
|
|
if path.len() < 2 {
|
|||
|
|
return Err("拖曳至少要兩個點(每個點要有數字 x 和 y,用截圖上的座標)".into());
|
|||
|
|
}
|
|||
|
|
let button = v["button"].as_str().unwrap_or("left");
|
|||
|
|
mac::drag(&path, button, flags_of(v));
|
|||
|
|
// Like the original's "Dragged from (x, y) to (x, y) with left button."
|
|||
|
|
let pts = v["path"].as_array().cloned().unwrap_or_default();
|
|||
|
|
let at = |p: &Value| format!("({:.0}, {:.0})", p["x"].as_f64().unwrap_or(0.0), p["y"].as_f64().unwrap_or(0.0));
|
|||
|
|
let side = match button {
|
|||
|
|
"right" => "右鍵",
|
|||
|
|
"middle" => "中鍵",
|
|||
|
|
_ => "左鍵",
|
|||
|
|
};
|
|||
|
|
Ok((format!("已從 {} 拖曳到 {}({side})。", pts.first().map(at).unwrap_or_default(), pts.last().map(at).unwrap_or_default()), None))
|
|||
|
|
}
|
|||
|
|
"computer_use_mouse_button" => {
|
|||
|
|
let button = v["button"].as_str().unwrap_or("left");
|
|||
|
|
let at = if v["x"].is_null() { None } else { Some(pt(v, scale()?)?) };
|
|||
|
|
let release = v["action"].as_str() == Some("release");
|
|||
|
|
mac::mouse_button(at, button, !release);
|
|||
|
|
Ok((if release { "已放開滑鼠鍵。" } else { "已按住滑鼠鍵(記得之後放開)。" }.into(), None))
|
|||
|
|
}
|
|||
|
|
"computer_use_typing" => {
|
|||
|
|
let text = v["value"].as_str().unwrap_or("");
|
|||
|
|
if text.is_empty() {
|
|||
|
|
return Err("請給要打的字".into());
|
|||
|
|
}
|
|||
|
|
mac::type_text(text);
|
|||
|
|
Ok((format!("已打字({} 個字)。", text.chars().count()), None))
|
|||
|
|
}
|
|||
|
|
"computer_use_press_key" => {
|
|||
|
|
let keys = parse_keys(v["key"].as_str().unwrap_or(""))?;
|
|||
|
|
let hold = v["hold_duration_ms"].as_u64().map(|ms| ms.min(MAX_HOLD_MS) as f64 / 1000.0).filter(|s| *s > 0.0).unwrap_or(KEY_HOLD_SECS);
|
|||
|
|
for (i, (code, flags)) in keys.iter().enumerate() {
|
|||
|
|
if i > 0 {
|
|||
|
|
std::thread::sleep(std::time::Duration::from_secs_f64(KEY_GAP_SECS));
|
|||
|
|
}
|
|||
|
|
mac::press(*code, *flags, hold);
|
|||
|
|
}
|
|||
|
|
Ok((format!("已按 {}。", v["key"].as_str().unwrap_or("")), None))
|
|||
|
|
}
|
|||
|
|
other => Err(format!("沒有「{other}」這個工具")),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// The app a screen action will land in: the window under the point for clicks, else the
|
|||
|
|
/// frontmost app. Used for the per-app permission (「AnyBot 可以看到 {app},並在其中點擊和輸入」).
|
|||
|
|
pub fn target_app(name: &str, args: &str) -> Option<String> {
|
|||
|
|
let v = args_of(args);
|
|||
|
|
let s = (*SCALE.lock().unwrap())?;
|
|||
|
|
let point = match name {
|
|||
|
|
"computer_use_click" | "computer_use_mouse_move" | "computer_use_scroll" | "computer_use_mouse_button" if !v["x"].is_null() => Some(to_screen(v["x"].as_f64()?, v["y"].as_f64()?, s)),
|
|||
|
|
"computer_use_drag" => v["path"].as_array().and_then(|a| a.first()).and_then(|p| Some(to_screen(p["x"].as_f64()?, p["y"].as_f64()?, s))),
|
|||
|
|
_ => None,
|
|||
|
|
};
|
|||
|
|
match point {
|
|||
|
|
Some((x, y)) => mac::window_at(x, y).map(|w| w.0),
|
|||
|
|
None => mac::front_app().map(|w| w.0),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Screenshot pixels per screen point (for the demonstration recorder).
|
|||
|
|
pub fn points_to_shot() -> f64 {
|
|||
|
|
let (pw, _) = mac::screen_points();
|
|||
|
|
let (px, py) = mac::screen_pixels();
|
|||
|
|
let (fw, _, _) = fit(px.max(1), py.max(1));
|
|||
|
|
if pw > 0.0 { fw as f64 / pw } else { 1.0 }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// The person's own clicks and keys can be recorded (教它一項任務): macOS, Linux on X11.
|
|||
|
|
/// Wayland doesn't let apps watch other apps' input.
|
|||
|
|
pub fn demo_supported() -> bool {
|
|||
|
|
#[cfg(target_os = "linux")]
|
|||
|
|
{
|
|||
|
|
crate::desk::kind() == crate::desk::Kind::X11
|
|||
|
|
}
|
|||
|
|
#[cfg(not(target_os = "linux"))]
|
|||
|
|
{
|
|||
|
|
cfg!(target_os = "macos")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// This system's screen can be seen and used (macOS; Linux on X11 or Wayland).
|
|||
|
|
pub fn screen_supported() -> bool {
|
|||
|
|
#[cfg(target_os = "linux")]
|
|||
|
|
{
|
|||
|
|
crate::desk::available()
|
|||
|
|
}
|
|||
|
|
#[cfg(not(target_os = "linux"))]
|
|||
|
|
{
|
|||
|
|
cfg!(target_os = "macos")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn screen_capture_ok() -> bool {
|
|||
|
|
mac::screen_capture_preflight()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
|||
|
|
pub fn frontmost_app() -> Option<String> {
|
|||
|
|
mac::front_app().map(|w| w.0)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
|||
|
|
pub fn app_at_point(x: f64, y: f64) -> Option<String> {
|
|||
|
|
mac::window_at(x, y).map(|w| w.0)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn screenshot() -> Result<(String, String), String> {
|
|||
|
|
if !mac::screen_capture_allowed() {
|
|||
|
|
let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture").spawn();
|
|||
|
|
return Err("macOS 還沒讓 AnyBot 錄製螢幕,所以看不到畫面。我已經打開「系統設定 → 隱私權與安全性 → 螢幕與系統錄音」:把 AnyBot(或「終端機」)打開,再叫我試一次。只要開一次。".into());
|
|||
|
|
}
|
|||
|
|
let img = capture_screen()?;
|
|||
|
|
let (w, h) = img.dimensions();
|
|||
|
|
let (fw, fh, resize) = fit(w, h);
|
|||
|
|
let img = if resize { image::imageops::resize(&img, fw, fh, image::imageops::FilterType::Triangle) } else { img };
|
|||
|
|
let jpeg = encode_jpeg(&img).ok_or("截圖太大了,沒辦法送出")?;
|
|||
|
|
let (pw, ph) = mac::screen_points();
|
|||
|
|
let scale = (if fw > 0 { pw / fw as f64 } else { 1.0 }, if fh > 0 { ph / fh as f64 } else { 1.0 });
|
|||
|
|
*SCALE.lock().unwrap() = Some(scale);
|
|||
|
|
let b64 = base64::engine::general_purpose::STANDARD.encode(&jpeg);
|
|||
|
|
Ok((format!("你的螢幕截圖({fw}×{fh})。之後的座標請用這張圖上的像素位置。"), b64))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// The whole screen as RGB (backing pixels).
|
|||
|
|
pub fn capture_screen() -> Result<image::RgbImage, String> {
|
|||
|
|
#[cfg(target_os = "linux")]
|
|||
|
|
return crate::desk::capture();
|
|||
|
|
#[allow(unreachable_code)]
|
|||
|
|
{
|
|||
|
|
let file = std::env::temp_dir().join(format!("anybot-screen-{}-{}.png", std::process::id(), crate::model::now_ms()));
|
|||
|
|
let st = std::process::Command::new("screencapture").args(["-x", "-m", "-t", "png"]).arg(&file).status().map_err(|e| e.to_string())?;
|
|||
|
|
let bytes = std::fs::read(&file);
|
|||
|
|
let _ = std::fs::remove_file(&file);
|
|||
|
|
if !st.success() {
|
|||
|
|
return Err("截圖失敗".into());
|
|||
|
|
}
|
|||
|
|
Ok(image::load_from_memory(&bytes.map_err(|e| format!("截圖失敗:{e}"))?).map_err(|e| format!("截圖讀不出來:{e}"))?.to_rgb8())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[cfg(target_os = "linux")]
|
|||
|
|
use crate::desk as mac;
|
|||
|
|
|
|||
|
|
#[cfg(target_os = "macos")]
|
|||
|
|
mod mac {
|
|||
|
|
use super::*;
|
|||
|
|
use std::ffi::c_void;
|
|||
|
|
use std::time::Duration;
|
|||
|
|
|
|||
|
|
#[repr(C)]
|
|||
|
|
#[derive(Clone, Copy)]
|
|||
|
|
struct CGPoint {
|
|||
|
|
x: f64,
|
|||
|
|
y: f64,
|
|||
|
|
}
|
|||
|
|
#[repr(C)]
|
|||
|
|
#[derive(Clone, Copy)]
|
|||
|
|
struct CGSize {
|
|||
|
|
width: f64,
|
|||
|
|
height: f64,
|
|||
|
|
}
|
|||
|
|
#[repr(C)]
|
|||
|
|
#[derive(Clone, Copy)]
|
|||
|
|
struct CGRect {
|
|||
|
|
origin: CGPoint,
|
|||
|
|
size: CGSize,
|
|||
|
|
}
|
|||
|
|
type Ev = *mut c_void;
|
|||
|
|
|
|||
|
|
type Src = *mut c_void;
|
|||
|
|
|
|||
|
|
#[link(name = "CoreGraphics", kind = "framework")]
|
|||
|
|
extern "C" {
|
|||
|
|
fn CGMainDisplayID() -> u32;
|
|||
|
|
fn CGDisplayBounds(d: u32) -> CGRect;
|
|||
|
|
fn CGPreflightScreenCaptureAccess() -> bool;
|
|||
|
|
fn CGRequestScreenCaptureAccess() -> bool;
|
|||
|
|
fn CGEventSourceCreate(state: i32) -> Src;
|
|||
|
|
fn CGEventSourceSetLocalEventsSuppressionInterval(src: Src, secs: f64);
|
|||
|
|
fn CGEventCreateMouseEvent(src: Src, kind: u32, at: CGPoint, button: u32) -> Ev;
|
|||
|
|
fn CGEventCreateKeyboardEvent(src: Src, key: u16, down: bool) -> Ev;
|
|||
|
|
fn CGEventKeyboardSetUnicodeString(e: Ev, len: usize, s: *const u16);
|
|||
|
|
fn CGEventSetFlags(e: Ev, flags: u64);
|
|||
|
|
fn CGEventSetIntegerValueField(e: Ev, field: u32, v: i64);
|
|||
|
|
fn CGEventPost(tap: u32, e: Ev);
|
|||
|
|
fn CGEventCreateScrollWheelEvent(src: Src, units: u32, count: u32, wheel1: i32, ...) -> Ev;
|
|||
|
|
fn CGEventPostToPid(pid: i32, e: Ev);
|
|||
|
|
fn CGDisplayPixelsWide(d: u32) -> usize;
|
|||
|
|
fn CGDisplayPixelsHigh(d: u32) -> usize;
|
|||
|
|
fn CGWindowListCopyWindowInfo(option: u32, relative_to: u32) -> *const c_void;
|
|||
|
|
fn CGRectMakeWithDictionaryRepresentation(dict: *const c_void, rect: *mut CGRect) -> bool;
|
|||
|
|
static kCGWindowOwnerName: *const c_void;
|
|||
|
|
static kCGWindowOwnerPID: *const c_void;
|
|||
|
|
static kCGWindowBounds: *const c_void;
|
|||
|
|
static kCGWindowLayer: *const c_void;
|
|||
|
|
}
|
|||
|
|
#[link(name = "CoreFoundation", kind = "framework")]
|
|||
|
|
extern "C" {
|
|||
|
|
fn CFArrayGetCount(a: *const c_void) -> isize;
|
|||
|
|
fn CFArrayGetValueAtIndex(a: *const c_void, i: isize) -> *const c_void;
|
|||
|
|
fn CFDictionaryGetValue(d: *const c_void, k: *const c_void) -> *const c_void;
|
|||
|
|
fn CFNumberGetValue(n: *const c_void, kind: isize, out: *mut c_void) -> bool;
|
|||
|
|
fn CFStringGetCString(s: *const c_void, buf: *mut std::ffi::c_char, len: isize, enc: u32) -> bool;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// On-screen windows front to back: (owner app, pid, bounds), normal layer only.
|
|||
|
|
fn windows() -> Vec<(String, i32, CGRect)> {
|
|||
|
|
let mut out = vec![];
|
|||
|
|
unsafe {
|
|||
|
|
// kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements
|
|||
|
|
let list = CGWindowListCopyWindowInfo(1 | 16, 0);
|
|||
|
|
if list.is_null() {
|
|||
|
|
return out;
|
|||
|
|
}
|
|||
|
|
for i in 0..CFArrayGetCount(list) {
|
|||
|
|
let d = CFArrayGetValueAtIndex(list, i);
|
|||
|
|
let mut layer: i32 = -1;
|
|||
|
|
let l = CFDictionaryGetValue(d, kCGWindowLayer);
|
|||
|
|
if l.is_null() || !CFNumberGetValue(l, 3, &mut layer as *mut i32 as *mut c_void) || layer != 0 {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
let mut pid: i32 = 0;
|
|||
|
|
let p = CFDictionaryGetValue(d, kCGWindowOwnerPID);
|
|||
|
|
if !p.is_null() {
|
|||
|
|
CFNumberGetValue(p, 3, &mut pid as *mut i32 as *mut c_void);
|
|||
|
|
}
|
|||
|
|
let mut name = String::new();
|
|||
|
|
let n = CFDictionaryGetValue(d, kCGWindowOwnerName);
|
|||
|
|
if !n.is_null() {
|
|||
|
|
let mut buf = [0u8; 256];
|
|||
|
|
if CFStringGetCString(n, buf.as_mut_ptr() as *mut std::ffi::c_char, buf.len() as isize, 0x0800_0100) {
|
|||
|
|
let end = buf.iter().position(|b| *b == 0).unwrap_or(buf.len());
|
|||
|
|
name = String::from_utf8_lossy(&buf[..end]).to_string();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
let mut r = CGRect { origin: CGPoint { x: 0.0, y: 0.0 }, size: CGSize { width: 0.0, height: 0.0 } };
|
|||
|
|
let b = CFDictionaryGetValue(d, kCGWindowBounds);
|
|||
|
|
if !b.is_null() {
|
|||
|
|
CGRectMakeWithDictionaryRepresentation(b, &mut r);
|
|||
|
|
}
|
|||
|
|
out.push((name, pid, r));
|
|||
|
|
}
|
|||
|
|
CFRelease(list);
|
|||
|
|
}
|
|||
|
|
out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// The app whose window is under a screen point.
|
|||
|
|
pub fn window_at(x: f64, y: f64) -> Option<(String, i32)> {
|
|||
|
|
windows().into_iter().find(|(_, _, r)| x >= r.origin.x && y >= r.origin.y && x < r.origin.x + r.size.width && y < r.origin.y + r.size.height).map(|(n, p, _)| (n, p))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// The frontmost app (its frontmost normal window).
|
|||
|
|
pub fn front_app() -> Option<(String, i32)> {
|
|||
|
|
windows().into_iter().find(|(n, _, r)| !n.is_empty() && r.size.width > 1.0).map(|(n, p, _)| (n, p))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn screen_capture_preflight() -> bool {
|
|||
|
|
unsafe { CGPreflightScreenCaptureAccess() }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn screen_pixels() -> (u32, u32) {
|
|||
|
|
let d = unsafe { CGMainDisplayID() };
|
|||
|
|
let (pw, ph) = screen_points();
|
|||
|
|
// CGDisplayPixelsWide reports points on Retina; screencapture gives backing pixels.
|
|||
|
|
let (w, h) = unsafe { (CGDisplayPixelsWide(d) as f64, CGDisplayPixelsHigh(d) as f64) };
|
|||
|
|
let scale = backing_scale();
|
|||
|
|
let _ = (w, h);
|
|||
|
|
((pw * scale) as u32, (ph * scale) as u32)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn backing_scale() -> f64 {
|
|||
|
|
static S: std::sync::OnceLock<f64> = std::sync::OnceLock::new();
|
|||
|
|
*S.get_or_init(|| {
|
|||
|
|
let file = std::env::temp_dir().join(format!("anybot-scale-{}.png", std::process::id()));
|
|||
|
|
let ok = std::process::Command::new("screencapture").args(["-x", "-m", "-t", "png", "-R0,0,10,10"]).arg(&file).status().map(|s| s.success()).unwrap_or(false);
|
|||
|
|
let w = if ok { image::image_dimensions(&file).map(|d| d.0).unwrap_or(10) } else { 10 };
|
|||
|
|
let _ = std::fs::remove_file(&file);
|
|||
|
|
(w as f64 / 10.0).max(1.0)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// A click delivered straight to an app without moving the pointer or raising its window
|
|||
|
|
/// (the original's "skylight-no-raise" path does this with a private API).
|
|||
|
|
pub fn click_in_background(pid: i32, x: f64, y: f64, button: &str, count: i64, flags: u64) {
|
|||
|
|
let (down, up, _, b) = kinds(button);
|
|||
|
|
let src = Source::new();
|
|||
|
|
for n in 1..=count {
|
|||
|
|
if n > 1 {
|
|||
|
|
sleep_secs(CLICK_REPEAT_GAP_SECS);
|
|||
|
|
}
|
|||
|
|
for (i, kind) in [down, up].into_iter().enumerate() {
|
|||
|
|
if i == 1 {
|
|||
|
|
sleep_secs(CLICK_HOLD_SECS);
|
|||
|
|
}
|
|||
|
|
let e = unsafe { CGEventCreateMouseEvent(src.0, kind, CGPoint { x, y }, b) };
|
|||
|
|
if e.is_null() {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
unsafe {
|
|||
|
|
CGEventSetIntegerValueField(e, CLICK_STATE, n);
|
|||
|
|
CGEventSetFlags(e, flags);
|
|||
|
|
CGEventPostToPid(pid, e);
|
|||
|
|
CFRelease(e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Press or release a mouse button where the pointer is (or at a point).
|
|||
|
|
pub fn mouse_button(at: Option<(f64, f64)>, button: &str, press: bool) {
|
|||
|
|
let (down, up, _, b) = kinds(button);
|
|||
|
|
let p = match at {
|
|||
|
|
Some((x, y)) => {
|
|||
|
|
move_to(x, y);
|
|||
|
|
gap();
|
|||
|
|
CGPoint { x, y }
|
|||
|
|
}
|
|||
|
|
None => current_point(),
|
|||
|
|
};
|
|||
|
|
let src = Source::new();
|
|||
|
|
let e = unsafe { CGEventCreateMouseEvent(src.0, if press { down } else { up }, p, b) };
|
|||
|
|
if !e.is_null() {
|
|||
|
|
unsafe { CGEventSetIntegerValueField(e, CLICK_STATE, 1) };
|
|||
|
|
}
|
|||
|
|
post(e, 0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn current_point() -> CGPoint {
|
|||
|
|
extern "C" {
|
|||
|
|
fn CGEventCreate(src: Src) -> Ev;
|
|||
|
|
fn CGEventGetLocation(e: Ev) -> CGPoint;
|
|||
|
|
}
|
|||
|
|
unsafe {
|
|||
|
|
let e = CGEventCreate(std::ptr::null_mut());
|
|||
|
|
if e.is_null() {
|
|||
|
|
return CGPoint { x: 0.0, y: 0.0 };
|
|||
|
|
}
|
|||
|
|
let p = CGEventGetLocation(e);
|
|||
|
|
CFRelease(e);
|
|||
|
|
p
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
#[link(name = "CoreFoundation", kind = "framework")]
|
|||
|
|
extern "C" {
|
|||
|
|
fn CFRelease(x: *const c_void);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const HID_TAP: u32 = 0;
|
|||
|
|
/// kCGEventSourceStateHIDSystemState, as CUActuator uses (CGEventSourceCreate(1)).
|
|||
|
|
const HID_STATE: i32 = 1;
|
|||
|
|
const CLICK_STATE: u32 = 1;
|
|||
|
|
const MOVED: u32 = 5;
|
|||
|
|
/// kCGScrollEventUnitPixel, as CGEventCreateScrollWheelEvent2(_, 0, 2, …) in the original.
|
|||
|
|
const SCROLL_PIXEL: u32 = 0;
|
|||
|
|
|
|||
|
|
/// An event source like the original's: HID system state, local-event suppression 0 s, so
|
|||
|
|
/// the person's own mouse and keys aren't frozen after each synthetic event.
|
|||
|
|
struct Source(Src);
|
|||
|
|
impl Source {
|
|||
|
|
fn new() -> Self {
|
|||
|
|
let s = unsafe { CGEventSourceCreate(HID_STATE) };
|
|||
|
|
if !s.is_null() {
|
|||
|
|
unsafe { CGEventSourceSetLocalEventsSuppressionInterval(s, 0.0) };
|
|||
|
|
}
|
|||
|
|
Source(s)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
impl Drop for Source {
|
|||
|
|
fn drop(&mut self) {
|
|||
|
|
if !self.0.is_null() {
|
|||
|
|
unsafe { CFRelease(self.0 as *const c_void) }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn post(e: Ev, flags: u64) {
|
|||
|
|
if e.is_null() {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
unsafe {
|
|||
|
|
CGEventSetFlags(e, flags);
|
|||
|
|
CGEventPost(HID_TAP, e);
|
|||
|
|
CFRelease(e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn gap() {
|
|||
|
|
std::thread::sleep(Duration::from_millis(EVENT_GAP_MS));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn sleep_secs(s: f64) {
|
|||
|
|
std::thread::sleep(Duration::from_secs_f64(s));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// (down, up, dragged) event kinds and button number.
|
|||
|
|
fn kinds(button: &str) -> (u32, u32, u32, u32) {
|
|||
|
|
match button {
|
|||
|
|
"right" => (3, 4, 7, 1),
|
|||
|
|
"middle" => (25, 26, 27, 2),
|
|||
|
|
_ => (1, 2, 6, 0),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn screen_capture_allowed() -> bool {
|
|||
|
|
unsafe { CGPreflightScreenCaptureAccess() || CGRequestScreenCaptureAccess() }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn screen_points() -> (f64, f64) {
|
|||
|
|
let r = unsafe { CGDisplayBounds(CGMainDisplayID()) };
|
|||
|
|
(r.size.width, r.size.height)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn move_to(x: f64, y: f64) {
|
|||
|
|
let src = Source::new();
|
|||
|
|
post(unsafe { CGEventCreateMouseEvent(src.0, MOVED, CGPoint { x, y }, 0) }, 0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// CUActuator.click: press, hold 0.03 s, release; 0.08 s between the clicks of a multi-click.
|
|||
|
|
pub fn click(x: f64, y: f64, button: &str, count: i64, flags: u64) {
|
|||
|
|
let (down, up, _, b) = kinds(button);
|
|||
|
|
let at = CGPoint { x, y };
|
|||
|
|
move_to(x, y);
|
|||
|
|
gap();
|
|||
|
|
let src = Source::new();
|
|||
|
|
for n in 1..=count {
|
|||
|
|
if n > 1 {
|
|||
|
|
sleep_secs(CLICK_REPEAT_GAP_SECS);
|
|||
|
|
}
|
|||
|
|
for (i, kind) in [down, up].into_iter().enumerate() {
|
|||
|
|
if i == 1 {
|
|||
|
|
sleep_secs(CLICK_HOLD_SECS);
|
|||
|
|
}
|
|||
|
|
let e = unsafe { CGEventCreateMouseEvent(src.0, kind, at, b) };
|
|||
|
|
if !e.is_null() {
|
|||
|
|
unsafe { CGEventSetIntegerValueField(e, CLICK_STATE, n) };
|
|||
|
|
}
|
|||
|
|
post(e, flags);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// CURemoteEventPoster.drag(path:button:steps:holdSeconds:): press at the first point (click
|
|||
|
|
/// state 1), hold, then walk each segment in `DRAG_STEPS` interpolated dragged events, then
|
|||
|
|
/// release at the last point.
|
|||
|
|
pub fn drag(path: &[(f64, f64)], button: &str, flags: u64) {
|
|||
|
|
let (down, up, dragged, b) = kinds(button);
|
|||
|
|
let (x0, y0) = path[0];
|
|||
|
|
move_to(x0, y0);
|
|||
|
|
gap();
|
|||
|
|
let src = Source::new();
|
|||
|
|
let event = |kind: u32, x: f64, y: f64| {
|
|||
|
|
let e = unsafe { CGEventCreateMouseEvent(src.0, kind, CGPoint { x, y }, b) };
|
|||
|
|
if !e.is_null() {
|
|||
|
|
unsafe { CGEventSetIntegerValueField(e, CLICK_STATE, 1) };
|
|||
|
|
}
|
|||
|
|
post(e, flags);
|
|||
|
|
};
|
|||
|
|
event(down, x0, y0);
|
|||
|
|
sleep_secs(DRAG_HOLD_SECS);
|
|||
|
|
for (x, y) in drag_points(path) {
|
|||
|
|
gap();
|
|||
|
|
event(dragged, x, y);
|
|||
|
|
}
|
|||
|
|
let (xn, yn) = path[path.len() - 1];
|
|||
|
|
event(up, xn, yn);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// handleScroll closure @0x10009e998: a mouse-moved event at the spot (the pointer's own
|
|||
|
|
/// position when no x/y), then one pixel-unit scroll event with 2 wheels at that spot.
|
|||
|
|
pub fn scroll(at: Option<(f64, f64)>, dy: i32, dx: i32, flags: u64) {
|
|||
|
|
if let Some((x, y)) = at {
|
|||
|
|
move_to(x, y);
|
|||
|
|
gap();
|
|||
|
|
}
|
|||
|
|
let src = Source::new();
|
|||
|
|
post(unsafe { CGEventCreateScrollWheelEvent(src.0, SCROLL_PIXEL, 2, dy, dx) }, flags);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// CUTypedCharacters.postChunks: each chunk on key 128, down, 0.035 s, up; 0.02 s between chunks.
|
|||
|
|
pub fn type_text(text: &str) {
|
|||
|
|
let src = Source::new();
|
|||
|
|
let chunks = utf16_chunks(text);
|
|||
|
|
for (i, chunk) in chunks.iter().enumerate() {
|
|||
|
|
if i > 0 {
|
|||
|
|
sleep_secs(KEY_GAP_SECS);
|
|||
|
|
}
|
|||
|
|
for down in [true, false] {
|
|||
|
|
if !down {
|
|||
|
|
sleep_secs(KEY_HOLD_SECS);
|
|||
|
|
}
|
|||
|
|
let e = unsafe { CGEventCreateKeyboardEvent(src.0, TYPE_KEY_CODE, down) };
|
|||
|
|
if !e.is_null() {
|
|||
|
|
unsafe { CGEventKeyboardSetUnicodeString(e, chunk.len(), chunk.as_ptr()) };
|
|||
|
|
}
|
|||
|
|
post(e, 0);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// handlePressKey: key down, hold (0.035 s unless told), key up.
|
|||
|
|
pub fn press(code: u16, flags: u64, hold_secs: f64) {
|
|||
|
|
let src = Source::new();
|
|||
|
|
post(unsafe { CGEventCreateKeyboardEvent(src.0, code, true) }, flags);
|
|||
|
|
sleep_secs(hold_secs);
|
|||
|
|
post(unsafe { CGEventCreateKeyboardEvent(src.0, code, false) }, flags);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
|||
|
|
mod mac {
|
|||
|
|
pub fn screen_capture_allowed() -> bool {
|
|||
|
|
false
|
|||
|
|
}
|
|||
|
|
pub fn screen_points() -> (f64, f64) {
|
|||
|
|
(0.0, 0.0)
|
|||
|
|
}
|
|||
|
|
pub fn move_to(_: f64, _: f64) {}
|
|||
|
|
pub fn click(_: f64, _: f64, _: &str, _: i64, _: u64) {}
|
|||
|
|
pub fn drag(_: &[(f64, f64)], _: &str, _: u64) {}
|
|||
|
|
pub fn scroll(_: Option<(f64, f64)>, _: i32, _: i32, _: u64) {}
|
|||
|
|
pub fn type_text(_: &str) {}
|
|||
|
|
pub fn press(_: u16, _: u64, _: f64) {}
|
|||
|
|
pub fn window_at(_: f64, _: f64) -> Option<(String, i32)> {
|
|||
|
|
None
|
|||
|
|
}
|
|||
|
|
pub fn front_app() -> Option<(String, i32)> {
|
|||
|
|
None
|
|||
|
|
}
|
|||
|
|
pub fn screen_capture_preflight() -> bool {
|
|||
|
|
false
|
|||
|
|
}
|
|||
|
|
pub fn screen_pixels() -> (u32, u32) {
|
|||
|
|
(0, 0)
|
|||
|
|
}
|
|||
|
|
pub fn click_in_background(_: i32, _: f64, _: f64, _: &str, _: i64, _: u64) {}
|
|||
|
|
pub fn mouse_button(_: Option<(f64, f64)>, _: &str, _: bool) {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[cfg(test)]
|
|||
|
|
mod tests {
|
|||
|
|
use super::*;
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn screenshots_fit_like_the_original() {
|
|||
|
|
assert_eq!(fit(2940, 1912), (1024, 666, true));
|
|||
|
|
assert_eq!(fit(1024, 640), (1024, 640, false));
|
|||
|
|
assert_eq!(fit(800, 600), (800, 600, false));
|
|||
|
|
assert_eq!(fit(4000, 7), (4000, 7, false));
|
|||
|
|
// A very wide strip keeps its short edge at 8.
|
|||
|
|
assert_eq!(fit(4000, 10), (3200, 8, true));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn jpeg_stays_under_the_cap() {
|
|||
|
|
let img = image::RgbImage::from_fn(1024, 640, |x, y| image::Rgb([(x % 256) as u8, (y % 256) as u8, ((x ^ y) % 256) as u8]));
|
|||
|
|
let j = encode_jpeg(&img).unwrap();
|
|||
|
|
assert!(j.len() <= MAX_IMAGE_BYTES && j.starts_with(&[0xFF, 0xD8]));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn shortcuts_are_parsed() {
|
|||
|
|
assert_eq!(parse_combo("cmd+c").unwrap(), (8, FLAG_CMD));
|
|||
|
|
assert_eq!(parse_combo("cmd+shift+t").unwrap(), (17, FLAG_CMD | FLAG_SHIFT));
|
|||
|
|
assert_eq!(parse_combo("Return").unwrap(), (36, 0));
|
|||
|
|
assert_eq!(parse_combo("ctrl+option+space").unwrap(), (49, FLAG_CONTROL | FLAG_OPTION));
|
|||
|
|
assert!(parse_combo("hyper+x").is_err());
|
|||
|
|
assert!(parse_combo("").is_err());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn typed_text_is_chunked_like_the_original() {
|
|||
|
|
let c = utf16_chunks(&"a".repeat(45));
|
|||
|
|
assert_eq!(c.iter().map(Vec::len).collect::<Vec<_>>(), vec![20, 20, 5]);
|
|||
|
|
// An emoji (2 UTF-16 units) is never split across chunks.
|
|||
|
|
let c = utf16_chunks(&format!("{}😀b", "a".repeat(19)));
|
|||
|
|
assert_eq!(c.iter().map(Vec::len).collect::<Vec<_>>(), vec![19, 3]);
|
|||
|
|
assert_eq!(utf16_chunks("你好").len(), 1);
|
|||
|
|
assert!(utf16_chunks("").is_empty());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn scrolls_like_the_original() {
|
|||
|
|
assert_eq!(scroll_amount(None), 3);
|
|||
|
|
assert_eq!(scroll_amount(Some(0)), 1);
|
|||
|
|
assert_eq!(scroll_amount(Some(99)), 20);
|
|||
|
|
assert_eq!(scroll_pixels("down", 3), Some((-48, 0)));
|
|||
|
|
assert_eq!(scroll_pixels("left", 2), Some((0, 32)));
|
|||
|
|
assert_eq!(scroll_pixels("sideways", 1), None);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn drags_walk_six_steps_per_segment() {
|
|||
|
|
let pts = drag_points(&[(0.0, 0.0), (60.0, 0.0), (60.0, 30.0)]);
|
|||
|
|
assert_eq!(pts.len(), 2 * DRAG_STEPS);
|
|||
|
|
assert_eq!(pts[0], (10.0, 0.0));
|
|||
|
|
assert_eq!(pts[DRAG_STEPS - 1], (60.0, 0.0));
|
|||
|
|
assert_eq!(*pts.last().unwrap(), (60.0, 30.0));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_press_can_be_several_keys() {
|
|||
|
|
assert_eq!(parse_keys("cmd+a delete").unwrap(), vec![(0, FLAG_CMD), (51, 0)]);
|
|||
|
|
assert!(parse_keys(" ").is_err());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn screenshot_pixels_map_to_screen_points() {
|
|||
|
|
// A 1470-point-wide screen sent as a 1024-wide picture.
|
|||
|
|
let s = (1470.0 / 1024.0, 956.0 / 666.0);
|
|||
|
|
let (x, y) = to_screen(512.0, 333.0, s);
|
|||
|
|
assert!((x - 735.0).abs() < 0.01 && (y - 478.0).abs() < 0.01);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[cfg(test)]
|
|||
|
|
mod window_tests {
|
|||
|
|
/// `cargo test --release -- --ignored finds_apps_on_screen --nocapture`
|
|||
|
|
#[test]
|
|||
|
|
#[ignore]
|
|||
|
|
fn finds_apps_on_screen() {
|
|||
|
|
let front = super::frontmost_app();
|
|||
|
|
println!("front: {front:?}");
|
|||
|
|
println!("at (200,200): {:?}", super::app_at_point(200.0, 200.0));
|
|||
|
|
println!("points→shot: {}", super::points_to_shot());
|
|||
|
|
assert!(front.is_some());
|
|||
|
|
}
|
|||
|
|
}
|