// 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; 12] = ["computer_use_screenshot", "computer_use_zoom", "computer_use_show", "computer_use_click", "computer_use_typing", "computer_use_press_key", "computer_use_scroll", "computer_use_mouse_move", "computer_use_drag", "computer_use_check_permissions", "computer_use_open_app", "computer_use_batch"]; /// Wait after an action before taking the picture that comes back with it (menus open, pages /// start to change; estimated). The box waits for the screen to stop changing instead, up to /// about this long. pub const SETTLE_MS: u64 = 500; /// Most actions one `computer_use_batch` call may carry (Anthropic's computer_batch guidance: /// short self-contained sequences, one picture at the end). pub const MAX_BATCH: usize = 16; /// A batch step's short action name → the tool it runs. Screen reads, apps and permissions stay /// single steps on purpose: a batch is for input you already planned from the current picture. pub fn action_tool(a: &str) -> Result<&'static str, String> { match a.trim().to_lowercase().as_str() { "click" => Ok("computer_use_click"), "typing" | "type" => Ok("computer_use_typing"), "press_key" | "key" => Ok("computer_use_press_key"), "scroll" => Ok("computer_use_scroll"), "mouse_move" | "move" => Ok("computer_use_mouse_move"), "drag" => Ok("computer_use_drag"), "" => Err("batch 的每個動作都要有 action(click、typing、press_key、scroll、mouse_move、drag)".into()), other => Err(format!("batch 裡沒有「{other}」這個動作(可用:click、typing、press_key、scroll、mouse_move、drag;要打開 App 請單獨用 computer_use_open_app)")), } } /// 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> = Mutex::new(None); /// Spawn a short-lived helper (`open`, a player, …) and reap it in the background: /// spawn-without-wait left one zombie per call for the app's whole life. pub fn spawn_reaped(c: &mut std::process::Command) -> std::io::Result<()> { let mut child = c.spawn()?; std::thread::spawn(move || { let _ = child.wait(); }); Ok(()) } /// 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) -> 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> { let mut out: Vec> = vec![]; let mut cur: Vec = 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 } /// One key of a press: a key with its modifiers, or a character typed (「@」「?」 have no key of /// their own on every keyboard; refusing them sent the bot looking for workarounds). #[derive(Debug, PartialEq)] pub enum Key { Combo(u16, u64), Char(String), } /// "cmd+a delete" → each key of one press, in order (handlePressKey presses a list of keys). pub fn key_plan(keys: &str) -> Result, String> { let list: Vec<&str> = keys.split_whitespace().collect(); if list.is_empty() { return Err("請給要按的鍵".into()); } list.into_iter() .map(|k| match parse_combo(k) { // A letter, digit or symbol without a modifier is a character: typed, so an input // method (注音) can't turn it into a syllable. Named symbol keys mean their character. Ok((c, 0)) if printable_of(c).is_some() => Ok(Key::Char(printable_of(c).unwrap_or_default())), Ok((c, f)) => Ok(Key::Combo(c, f)), Err(_) if k.chars().count() == 1 => Ok(Key::Char(k.to_string())), Err(e) => Err(e), }) .collect() } /// The character a printing key gives without shift (US layout), or None for keys like return. fn printable_of(code: u16) -> Option { 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]; if let Some(i) = LETTERS.iter().position(|c| *c == code) { return Some(((b'a' + i as u8) as char).to_string()); } if let Some(i) = DIGITS.iter().position(|c| *c == code) { return Some(((b'0' + i as u8) as char).to_string()); } let c = match code { 27 => '-', 24 => '=', 33 => '[', 30 => ']', 41 => ';', 39 => '\'', 43 => ',', 47 => '.', 44 => '/', 42 => '\\', 50 => '`', _ => return None, }; Some(c.to_string()) } /// The picture the bot and the phone get of this screen: its size, always the same for the same /// screen. Coordinates on it map to the screen without remembering any earlier screenshot (that /// hidden state made clicks fail with "screenshot first" and land wrong for a viewer whose /// picture was a different size). pub fn shot_size() -> (u32, u32) { let (px, py) = mac::screen_pixels(); let (w, h, _) = fit(px.max(1), py.max(1)); (w, h) } /// Screen points (the mouse's units) per picture pixel. pub fn scale_here() -> (f64, f64) { let (pw, ph) = mac::screen_points(); let (w, h) = shot_size(); (pw / w.max(1) as f64, ph / h.max(1) as f64) } /// A click, scroll or drag: a step whose whole effect should show on the screen. Only these are /// called out when the picture didn't change (typing a few letters or a key can change too little /// for the coarse fingerprint, and the bot was told to stop typing that worked). pub fn is_pointer_step(name: &str) -> bool { matches!(name, "computer_use_click" | "computer_use_scroll" | "computer_use_drag") } /// What the bot is told after computer_use_show (the picture itself goes into the chat). pub const SHOWN_NOTE: &str = "已把這台電腦現在的畫面(使用者看到的樣子,沒有編號)放進聊天,使用者看得到這張圖;你也看得到,可以用一兩句話說明畫面上有什麼"; 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_zoom" => "放大看", "computer_use_show" => "把畫面傳給你看", "computer_use_click" => "點一下", "computer_use_typing" => "打字", "computer_use_press_key" => "按鍵", "computer_use_scroll" => "捲動", "computer_use_mouse_move" => "移動滑鼠", "computer_use_drag" => "拖曳", "computer_use_check_permissions" => "檢查螢幕權限", "computer_use_open_app" => "打開 App", "computer_use_batch" => "依序操作", _ => "操作畫面", } } pub fn describe(name: &str, args: &str) -> String { let v = crate::tools::args_of(args); // By number from the app's list (e7), else where on the screen (it said "(0, 0)" for every // click by number). let xy = || match v["ref"].as_str().map(str::trim).filter(|r| !r.is_empty()) { Some(r) => format!("#{}", r.trim_start_matches(['e', '#'])), None => 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_zoom" => format!("放大 ({}, {}) 起 {}×{}", v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0), v["width"].as_f64().unwrap_or(0.0), v["height"].as_f64().unwrap_or(0.0)), "computer_use_show" => "畫面傳進聊天".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(); let at = v["ref"].as_str().filter(|r| !r.trim().is_empty()).map(|r| format!("在 {r} ")).unwrap_or_default(); format!("{at}「{short}{}」{}", if t.chars().count() > 60 { "…" } else { "" }, if v["submit"].as_bool().unwrap_or(false) { " 再按 return" } 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_check_permissions" => "螢幕錄製、輔助使用".into(), "computer_use_open_app" => v["app"].as_str().filter(|a| !a.is_empty()).unwrap_or("").to_string(), "computer_use_batch" => format!("依序 {} 個動作", v["actions"].as_array().map(|a| a.len()).unwrap_or(0)), _ => String::new(), } } /// The screen tools, each with a `computer` choice. One way to see (the numbered picture), one /// way to act (a real click or keystroke, best by number). pub fn specs_for(computers: &[String], default: &str, chosen: bool) -> Vec { let mods = json!({"type": "array", "items": {"type": "string", "enum": ["cmd", "shift", "option", "control"]}}); let rf = json!({"type": "string", "description": "The number of a control on the latest picture (e.g. 12)."}); let named = json!({"type": "string", "description": "Or the control's name exactly as the latest list shows it (e.g. 4, Close, 自動更新): the numbered control with that name. Easiest for keys and buttons with plain names; use ref when two have the same name."}); let mut v = vec![ json!({"name": "computer_use_screenshot", "description": "See a computer's screen: a picture with every button, link, field and menu item boxed and numbered, and the same numbers in a list with their names. Act on a control by its number (ref). Every action below already returns the next picture, so call this only to start or to look again.", "parameters": {"type": "object", "properties": {}}}), json!({"name": "computer_use_zoom", "description": "Look closer at part of the latest picture, at the screen's full sharpness: small icons (the menu bar, a toolbar, a tray), small print, a tiny control. Give the area in the latest picture's pixels. You get a bigger, sharper picture of just that area; to act there, use the numbers on the normal picture, or turn a point in the zoomed picture back into the normal picture's pixels as the result explains. Use it instead of guessing, and never make your own zoom with commands.", "parameters": {"type": "object", "properties": {"x": {"type": "number", "description": "Left edge, in the latest picture's pixels"}, "y": {"type": "number", "description": "Top edge"}, "width": {"type": "number"}, "height": {"type": "number"}}, "required": ["x", "y", "width", "height"]}}), json!({"name": "computer_use_show", "description": "Show the person a computer's screen: a picture of it as they would see it (no numbers) goes into the chat for them, and you see it too. Use it when they ask to see a screen (截圖給我看、讓我看看畫面). One call, no file needed.", "parameters": {"type": "object", "properties": {}}}), json!({"name": "computer_use_click", "description": "Click a control: by its name as the latest list shows it (name, e.g. 4, Close, 自動更新 — easiest, no number to look up), or by its number (ref) when two share a name. Only for something with neither (a spot in a picture or game): x, y in the picture's pixels. Returns the next picture.", "parameters": {"type": "object", "properties": {"ref": rf, "name": named, "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}}}), json!({"name": "computer_use_typing", "description": "Type text (any language) into a field by its number (ref; it is clicked first), or where the cursor is. submit=true presses return after it (send, search, run a command). Returns the next picture.", "parameters": {"type": "object", "properties": {"value": {"type": "string"}, "ref": rf, "name": named, "submit": {"type": "boolean"}}, "required": ["value"]}}), json!({"name": "computer_use_press_key", "description": "Press a key or shortcut in the app in front: return, tab, escape, delete, up, slash, cmd+c, cmd+shift+t, cmd+space. Several in order: separate with spaces (\"cmd+a delete\"). On a Linux computer cmd means ctrl. Returns the next picture.", "parameters": {"type": "object", "properties": {"key": {"type": "string"}, "hold_duration_ms": {"type": "integer"}}, "required": ["key"]}}), json!({"name": "computer_use_scroll", "description": "Scroll a list or page: on a control by number (ref), or at x, y, or where the pointer is. Returns the next picture.", "parameters": {"type": "object", "properties": {"ref": rf, "name": named, "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_open_app", "description": "Open an app, or bring it to the front when it is already open (its name as shown, e.g. Ghostty, Calculator, 計算機, Chromium, xterm). Returns the next picture.", "parameters": {"type": "object", "properties": {"app": {"type": "string"}}, "required": ["app"]}}), json!({"name": "computer_use_mouse_move", "description": "Move the mouse onto a control by number (ref) or to x, y (to open a hover menu). Returns the next picture.", "parameters": {"type": "object", "properties": {"ref": rf, "name": named, "x": {"type": "number"}, "y": {"type": "number"}}}}), json!({"name": "computer_use_drag", "description": "Drag along a path of points in the picture's pixels (first = press, last = release). Returns the next picture.", "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_check_permissions", "description": "Check whether the screen can be seen and the mouse and keyboard used on that computer.", "parameters": {"type": "object", "properties": {}}}), json!({"name": "computer_use_batch", "description": format!("Do a planned sequence of steps in one call (max {MAX_BATCH}): each item is one action with its own arguments, best naming the control, e.g. {{\"action\":\"click\",\"name\":\"7\"}}, {{\"action\":\"typing\",\"name\":\"Search\",\"value\":\"cat\"}}, {{\"action\":\"press_key\",\"key\":\"tab\"}}. Use it when the current picture already tells you every step (a row of keys to press, filling a form, a known shortcut chain): put the whole sequence in one call, they run in order, the first step that fails stops the rest, and you get one picture at the end. Several separate clicks in one step are slower and easier to get wrong than one batch. Do not use it to explore, or where step 2 depends on what step 1's screen shows — act one step at a time there."), "parameters": {"type": "object", "properties": {"actions": {"type": "array", "maxItems": MAX_BATCH, "items": {"type": "object", "properties": {"action": {"type": "string", "enum": ["click", "typing", "press_key", "scroll", "mouse_move", "drag"]}, "ref": rf.clone(), "name": named.clone(), "x": {"type": "number"}, "y": {"type": "number"}, "button": {"type": "string", "enum": ["left", "right", "middle"]}, "count": {"type": "integer"}, "value": {"type": "string"}, "submit": {"type": "boolean"}, "key": {"type": "string"}, "hold_duration_ms": {"type": "integer"}, "direction": {"type": "string", "enum": ["up", "down", "left", "right"]}, "amount": {"type": "integer"}, "path": {"type": "array", "items": {"type": "object", "properties": {"x": {"type": "number"}, "y": {"type": "number"}}, "required": ["x", "y"]}}, "modifier_keys": mods.clone()}, "required": ["action"]}}}, "required": ["actions"]}}), ]; // Always named: each computer goes by its own name (the instructions say which is which), // so the bot says whose screen it means and the person can tell where it worked. if !computers.is_empty() { // The person's chosen computer may have no screen (a bot's computer without Docker): then // the default is only the first screen there is, and the bot is told so. let why = if chosen { "the one the person chose" } else { "the computer the person chose has no screen" }; let desc = format!("Whose screen, by the computer's name. Default: {default} ({why}). Use another only when the person names it."); for t in v.iter_mut() { t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": computers, "description": desc}); } } v } /// Longest edge of a zoomed picture (sharp enough to read a menu-bar icon, still one image). pub const ZOOM_LONG_EDGE: u32 = 1024; /// A zoom never blows up the real pixels more than this (it would only blur). pub const ZOOM_MAX_UPSCALE: f64 = 4.0; /// computer_use_zoom: an area of the latest picture (the bot's pixels, see `fit`) cut from the /// full-sharpness screen `img` and enlarged. Returns the text for the bot and the JPEG (base64). pub fn zoom(img: &image::RgbImage, v: &Value) -> Result<(String, String), String> { let (w, h) = img.dimensions(); let (fw, fh, _) = fit(w, h); let scale = w as f64 / fw.max(1) as f64; let num = |k: &str| v[k].as_f64().filter(|n| n.is_finite()); let (Some(x), Some(y), Some(zw), Some(zh)) = (num("x"), num("y"), num("width"), num("height")) else { return Err("放大要給 x、y、width、height(最新那張截圖上的像素)".into()); }; // In the bot's picture, clamped to it; at least 8 picture pixels each way. let x0 = x.clamp(0.0, fw as f64 - 8.0); let y0 = y.clamp(0.0, fh as f64 - 8.0); let x1 = (x + zw).clamp(x0 + 8.0, fw as f64); let y1 = (y + zh).clamp(y0 + 8.0, fh as f64); // The same area in real screen pixels. let (sx, sy) = ((x0 * scale).floor() as u32, (y0 * scale).floor() as u32); let sw = (((x1 - x0) * scale).ceil() as u32).clamp(1, w - sx.min(w - 1)); let sh = (((y1 - y0) * scale).ceil() as u32).clamp(1, h - sy.min(h - 1)); let crop = image::imageops::crop_imm(img, sx, sy, sw, sh).to_image(); let up = (ZOOM_LONG_EDGE as f64 / sw.max(sh) as f64).min(ZOOM_MAX_UPSCALE); let pic = if (up - 1.0).abs() > 0.05 { let (tw, th) = (((sw as f64 * up).round() as u32).max(1), ((sh as f64 * up).round() as u32).max(1)); image::imageops::resize(&crop, tw, th, image::imageops::FilterType::CatmullRom) } else { crop }; let (pw, ph) = pic.dimensions(); let jpeg = encode_jpeg(&pic).ok_or("放大的圖太大了,沒辦法送出")?; // How a point in the zoomed picture maps back to the normal picture. let k = (x1 - x0) / pw as f64; let text = format!( "放大圖({pw}×{ph}):最新截圖上 ({:.0}, {:.0}) 到 ({:.0}, {:.0}) 的區域,用螢幕原本的清晰度。這張圖上的點 (a, b) 在一般截圖上是 ({:.0} + a×{k:.3}, {:.0} + b×{k:.3});有編號的東西還是照編號點。", x0, y0, x1, y1, x0, y0 ); Ok((text, base64::engine::general_purpose::STANDARD.encode(&jpeg))) } /// 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> { let mut best: Option> = 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().is_none_or(|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 { 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, // Symbol keys by name (a model writes "slash" as often as "/"). "slash" => 44, "backslash" => 42, "comma" => 43, "period" | "dot" => 47, "semicolon" => 41, "quote" | "apostrophe" => 39, "minus" | "hyphen" => 27, "equal" | "equals" => 24, "grave" | "backtick" | "backquote" => 50, "bracketleft" | "leftbracket" => 33, "bracketright" | "rightbracket" => 30, _ => 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 { 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). pub fn run(name: &str, args: &str) -> (String, bool, Option) { 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) { if moves_things(name) { *LAST_ACTION.lock().unwrap_or_else(|e| e.into_inner()) = Some((std::time::Instant::now(), who.to_string())); } match run_inner(name, &crate::tools::args_of(args)) { Ok((t, img)) => (t, true, img), Err(e) => (e, false, None), } } /// Steps that use the mouse or keyboard (and come back with the screen after them). pub fn moves_things(name: &str) -> bool { matches!(name, "computer_use_click" | "computer_use_typing" | "computer_use_press_key" | "computer_use_scroll" | "computer_use_mouse_move" | "computer_use_drag" | "computer_use_open_app" | "computer_use_batch") } /// Ask macOS for a permission (its own prompt, then 系統設定 at that pane) at most once every ten /// minutes. Asking on every step meant a bot working through several steps — or a brain far away /// working on this computer — covered the screen with prompts and Settings windows, and the /// person could do nothing else. pub fn ask_permission(pane: &str) { static LAST: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); { let mut l = LAST.lock().unwrap_or_else(|e| e.into_inner()); if l.iter().any(|(p, t)| p == pane && t.elapsed() < std::time::Duration::from_secs(600)) { return; } l.retain(|(p, _)| p != pane); l.push((pane.to_string(), std::time::Instant::now())); } if !cfg!(target_os = "macos") { return; } match pane { "Privacy_Accessibility" => { let _ = crate::axread::trusted(true); } #[cfg(target_os = "macos")] "Privacy_ScreenCapture" => { let _ = mac::screen_capture_allowed(); } _ => {} } let _ = spawn_reaped(std::process::Command::new("open").arg(format!("x-apple.systempreferences:com.apple.preference.security?{pane}"))); } const NO_ACCESSIBILITY: &str = "macOS 還沒讓 AnyBot 使用「輔助使用」,所以不能讀 App,也不能動滑鼠和鍵盤。這台電腦上已經跳出詢問並打開「系統設定 → 隱私權與安全性 → 輔助使用」:請使用者把 AnyBot 打開(只要一次),再叫我試一次。"; const NO_SCREEN_RECORDING: &str = "macOS 還沒讓 AnyBot 錄製螢幕,所以看不到畫面。這台電腦上已經打開「系統設定 → 隱私權與安全性 → 螢幕與系統錄音」:請使用者把 AnyBot 打開(只要一次)。在那之前 computer_use_screenshot 還是會列出最前面 App 的文字和按鈕編號(只是沒有圖)。"; /// Reading apps and using the mouse and keyboard need 輔助使用 on macOS (other systems: always). fn controls_allowed() -> bool { !cfg!(target_os = "macos") || crate::axread::trusted(false) } fn need_controls() -> Result<(), String> { if controls_allowed() { return Ok(()); } ask_permission("Privacy_Accessibility"); Err(NO_ACCESSIBILITY.into()) } /// The app a shown name stands for (「計算機」 → Calculator), from the apps' display names. #[cfg(target_os = "macos")] fn app_named(name: &str) -> Option { if name.is_empty() || name.contains(['"', '\\', '\'']) { return None; } let q = format!("kMDItemDisplayName == \"{name}\"cd && kMDItemContentType == \"com.apple.application-bundle\""); let out = std::process::Command::new("mdfind").arg(q).output().ok()?; let path = String::from_utf8_lossy(&out.stdout).lines().next()?.trim().to_string(); std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().to_string()) } #[cfg(not(target_os = "macos"))] fn app_named(_name: &str) -> Option { None } /// Open an app or bring it to the front. A name as the person sees it (「計算機」 on a Chinese /// Mac) isn't one the system launches by; find the app it is and launch that. fn open_app(app: &str) -> Result { let app = app.trim(); if app.is_empty() { return Err("請給要打開的 App 名稱".into()); } let real = app_named(app); launch(app).or_else(|e| match &real { Some(r) if r != app => launch(r), _ => Err(e), })?; // Wait for its window to be in front (switching desktops takes a moment) before looking. let start = std::time::Instant::now(); while start.elapsed() < std::time::Duration::from_millis(2500) { let front = mac::front_app().map(|f| f.0.to_lowercase()).unwrap_or_default(); if front == app.to_lowercase() || real.as_ref().map(|r| front == r.to_lowercase()).unwrap_or(false) { break; } std::thread::sleep(std::time::Duration::from_millis(100)); } Ok(format!("已打開「{app}」,放在最前面。")) } /// Open an app or bring it forward. macOS: LaunchServices (`open -a`), which needs no /// permission (agent-desktop's launch waited on the window through accessibility and failed /// without 輔助使用 now and then); elsewhere agent-desktop's. fn launch(app: &str) -> Result<(), String> { #[cfg(target_os = "macos")] { let out = std::process::Command::new("open").args(["-a", app]).output().map_err(|e| e.to_string())?; if out.status.success() { return Ok(()); } Err(format!("找不到「{app}」這個 App")) } #[cfg(not(target_os = "macos"))] crate::desktop::launch(app) } /// Bring the app a control belongs to in front, so a click at its spot lands in it (and not in /// a window lying over it). Nothing to do when it is in front already. fn bring_forward(app: &str) { // macOS only: there "launch" brings a running app forward; on Linux it can start a second // copy, and the names a window and its accessibility give differ. if !cfg!(target_os = "macos") { return; } let front = mac::front_app().map(|f| f.0).unwrap_or_default(); if app.is_empty() || front.eq_ignore_ascii_case(app) { return; } if launch(app).is_ok() { std::thread::sleep(std::time::Duration::from_millis(300)); } } /// The numbered controls of this computer's latest look, and the app they belong to. static SPOTS: Mutex> = Mutex::new(None); /// How long the menu bar's status icons are trusted before they are read again (they rarely move). #[cfg(target_os = "macos")] const MENU_BAR_FRESH_SECS: u64 = 15; /// The menu bar's status icons as numbered controls, from every app's AXExtrasMenuBar. #[cfg(target_os = "macos")] fn menu_bar_controls() -> Vec { static CACHE: Mutex)>> = Mutex::new(None); static REFRESHING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); let cached = CACHE.lock().unwrap_or_else(|e| e.into_inner()).clone(); if let Some((t, v)) = cached { // Stale: use what there is now, and read them again in the background for the next look. if t.elapsed().as_secs() >= MENU_BAR_FRESH_SECS && !REFRESHING.swap(true, std::sync::atomic::Ordering::SeqCst) { std::thread::spawn(|| { let fresh = read_menu_bar(); *CACHE.lock().unwrap_or_else(|e| e.into_inner()) = Some((std::time::Instant::now(), fresh)); REFRESHING.store(false, std::sync::atomic::Ordering::SeqCst); }); } return v; } let els = read_menu_bar(); *CACHE.lock().unwrap_or_else(|e| e.into_inner()) = Some((std::time::Instant::now(), els.clone())); els } #[cfg(target_os = "macos")] fn read_menu_bar() -> Vec { crate::axread::status_items(&app_processes()) .into_iter() .map(|it| crate::marks::Element { kind: "選單列圖示".into(), name: icon_name(&it.app, &it.label), value: String::new(), x: it.rect[0], y: it.rect[1], w: it.rect[2], h: it.rect[3], active: true, flags: vec![], window: "選單列".into(), }) .collect() } /// How a status icon is listed: the system's own (hosted by an agent process) by their words /// alone (「Wi‑Fi,已連接」, not 「MenuBarAgent:Wi‑Fi…」), an app's as 「app:words」. #[cfg_attr(not(target_os = "macos"), allow(dead_code))] fn icon_name(app: &str, label: &str) -> String { let system = app.ends_with("Agent") || matches!(app, "ControlCenter" | "Control Center" | "SystemUIServer" | "Siri AI" | "Spotlight"); match (label.trim(), system) { ("", _) => app.to_string(), (l, true) => l.to_string(), (l, false) if l == app => l.to_string(), (l, false) => format!("{app}:{l}"), } } /// This user's running apps and app helpers (anything run from an .app bundle): pid and name. #[cfg(target_os = "macos")] fn app_processes() -> Vec<(i32, String)> { let Ok(out) = std::process::Command::new("/bin/ps").args(["-x", "-o", "pid=,comm="]).output() else { return vec![] }; String::from_utf8_lossy(&out.stdout) .lines() .filter_map(|l| { let (pid, path) = l.trim_start().split_once(' ')?; let path = path.trim(); let i = path.find(".app/Contents/MacOS/")?; let name = path[..i].rsplit('/').next()?.to_string(); Some((pid.parse().ok()?, name)) }) .collect() } /// The latest look's fingerprint (to say when a step changed nothing). static LAST_LOOK: Mutex> = Mutex::new(None); /// Where a step acts: a control's number on the latest picture, or x, y on the picture. /// Returns the screen point and how to say it (「#7(按鈕「7」)」, 「(512, 300)」). fn place_of(v: &Value, scale: (f64, f64)) -> Result, String> { if let Some(r) = v["ref"].as_str().map(str::trim).filter(|r| !r.is_empty()).or_else(|| v["ref"].as_u64().map(|_| "")).map(|r| if r.is_empty() { v["ref"].to_string() } else { r.to_string() }) { let (app, spots) = SPOTS.lock().unwrap_or_else(|e| e.into_inner()).clone().ok_or("先用 computer_use_screenshot 看一下畫面,再照圖上的編號操作。")?; let ((x, y), what) = spots.get(&r)?; bring_forward(&app); return Ok(Some((x, y, format!("#{}({what})", crate::marks::number_of(&r).unwrap_or(0))))); } match (v["x"].as_f64(), v["y"].as_f64()) { (Some(x), Some(y)) => { let (sx, sy) = to_screen(x, y, scale); Ok(Some((sx, sy, format!("({x}, {y})")))) } (None, None) if v["x"].is_null() && v["y"].is_null() => Ok(None), _ => Err("x 和 y 要一起給,而且要是數字(截圖上的位置)".into()), } } /// Press each key of "cmd+a delete" in order. fn press_keys(keys: &str, hold: f64) -> Result<(), String> { for (i, k) in key_plan(keys)?.iter().enumerate() { if i > 0 { std::thread::sleep(std::time::Duration::from_secs_f64(KEY_GAP_SECS)); } match k { Key::Combo(code, flags) => mac::press(*code, *flags, hold), Key::Char(ch) => mac::type_text(ch), } } Ok(()) } /// Run a planned sequence of actions, then one picture (in the box the box's own batch runs; /// here, the local screen). The first action that fails stops the rest — acting on a screen /// that isn't where you think it is makes it worse (Anthropic's computer_batch rule). pub fn batch(v: &Value, scale: (f64, f64)) -> Result { let acts = v["actions"].as_array().ok_or("computer_use_batch 要給 actions:動作陣列,每個有 action(click、typing、press_key、scroll、mouse_move、drag)和它的參數")?; if acts.is_empty() { return Err("actions 是空的".into()); } if acts.len() > MAX_BATCH { return Err(format!("一次最多 {MAX_BATCH} 個動作({} 個太多了)", acts.len())); } let mut notes = Vec::new(); for (i, a) in acts.iter().enumerate() { let name = action_tool(a["action"].as_str().unwrap_or(""))?; match act(name, a, scale) { Ok(n) => notes.push(format!("{}. {n}", i + 1)), Err(e) => { let e = plain_words(e); if notes.is_empty() { return Err(format!("第 {} 步就失敗:{e}", i + 1)); } notes.push(format!("{}. 失敗:{e}(後面 {} 個沒有做)", i + 1, acts.len() - i - 1)); break; } } } Ok(format!("已依序做完 {} 步:\n{}", notes.len(), notes.join("\n"))) } /// Do one mouse or keyboard step at the screen's own coordinates (`scale`: screen points per /// picture pixel). The bot's tools and a person tapping on the phone's picture both come here: /// one way of acting on a screen. pub fn act(name: &str, v: &Value, scale: (f64, f64)) -> Result { let button = v["button"].as_str().unwrap_or("left"); match name { "computer_use_click" => { let (x, y, at) = place_of(v, scale)?.ok_or("請給 x、y(截圖上的位置)或 ref(截圖上的編號)")?; let count = v["count"].as_u64().unwrap_or(1).clamp(1, 3) as i64; mac::click(x, y, button, count, flags_of(v)); let how = format!("{}{}", if button == "right" { "右鍵" } else { "" }, if count >= 2 { format!("連點 {count} 下") } else { "點".into() }); Ok(format!("已{how} {at}。")) } "computer_use_mouse_move" => { let (x, y, at) = place_of(v, scale)?.ok_or("請給 x 和 y")?; mac::move_to(x, y); Ok(format!("已把滑鼠移到 {at}。")) } "computer_use_scroll" => { // CURemoteToolService.handleScroll @0x10009e200 / closure @0x10009e998. let at = place_of(v, scale)?; 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.as_ref().map(|(x, y, _)| (*x, *y)), dy, dx, flags_of(v)); Ok(format!("已捲動 {dir} ×{amount}{}。", at.map(|(_, _, a)| format!(",在 {a}")).unwrap_or_default())) } "computer_use_drag" => { // Every point must be valid: points that failed to parse used to be dropped in // silence, so the drag ran on fewer points and reported success at the wrong place. let raw = v["path"].as_array().ok_or("拖曳要給 path:一個點陣,每個點有數字 x 和 y(用截圖上的座標)")?; let pts: Vec<(f64, f64)> = raw.iter().map(|p| match (p["x"].as_f64(), p["y"].as_f64()) { (Some(x), Some(y)) => Ok((x, y)), _ => Err("拖曳的每個點都要有數字 x 和 y(用截圖上的座標)".to_string()), }).collect::>()?; if pts.len() < 2 { return Err("拖曳至少要兩個點(每個點要有數字 x 和 y,用截圖上的座標)".into()); } let path: Vec<(f64, f64)> = pts.iter().map(|(x, y)| to_screen(*x, *y, scale)).collect(); mac::drag(&path, button, flags_of(v)); // Like the original's "Dragged from (x, y) to (x, y) with left button." let side = match button { "right" => "右鍵", "middle" => "中鍵", _ => "左鍵", }; let (a, b) = (pts[0], pts[pts.len() - 1]); Ok(format!("已從 ({:.0}, {:.0}) 拖曳到 ({:.0}, {:.0})({side})。", a.0, a.1, b.0, b.1)) } "computer_use_typing" => { let text = v["value"].as_str().unwrap_or(""); let submit = v["submit"].as_bool().unwrap_or(false); if text.is_empty() && !submit { return Err("請給要打的字".into()); } // Into a field by its number: click it first, like a person would. let at = place_of(&json!({"ref": v["ref"]}), scale)?; if let Some((x, y, _)) = &at { mac::click(*x, *y, "left", 1, 0); std::thread::sleep(std::time::Duration::from_millis(150)); } if !text.is_empty() { mac::type_text(text); } if submit { std::thread::sleep(std::time::Duration::from_millis(60)); mac::press(36, 0, KEY_HOLD_SECS); } Ok(format!("已打字({} 個字){}{}。", text.chars().count(), at.map(|(_, _, a)| format!("在 {a}")).unwrap_or_default(), if submit { ",再按了 return" } else { "" })) } "computer_use_press_key" => { 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); press_keys(v["key"].as_str().unwrap_or(""), hold)?; Ok(format!("已按 {}。", v["key"].as_str().unwrap_or(""))) } "computer_use_open_app" => open_app(v["app"].as_str().unwrap_or("")), other => Err(format!("沒有「{other}」這個工具")), } } /// What the screen looks like now: the picture with the controls of the app in front boxed and /// numbered, and the list of them. Without a picture (no 螢幕錄製), the list with the app's text. /// After a step (`after`), it also says when nothing changed, so the bot doesn't click the same /// empty place again and again. fn look(after: Option, pointer: bool) -> Result<(String, Option), String> { let picture = if mac::screen_capture_preflight() { Some(capture_screen().map_err(|e| format!("看不到畫面:{e}"))?) } else { None }; // The app in front: on Linux the window system's own answer when it gives one (agent-desktop // alone goes by AT-SPI's "active" window, which some desktops never mark: no numbers, 「」). // agent-desktop knows an app by its program's name (a Snap Firefox's window says // "firefox_firefox", its program is "firefox"). #[cfg(target_os = "linux")] let front = mac::front_app().map(|(n, pid)| crate::desk::program_of(pid).unwrap_or(n)).unwrap_or_default(); #[cfg(not(target_os = "linux"))] let front = String::new(); let (found, why) = if controls_allowed() { match crate::desktop::elements(&front).or_else(|e| if front.is_empty() { Err(e) } else { crate::desktop::elements("") }) { Ok(f) => (Some(f), String::new()), Err(e) => (None, e), } } else { (None, String::new()) }; if picture.is_none() && found.is_none() { ask_permission("Privacy_ScreenCapture"); return Err(NO_SCREEN_RECORDING.into()); } // No controls at all, and the desktop's accessibility switch was off: turn it on (Linux). #[cfg(target_os = "linux")] let a11y_note = if controls_allowed() && found.as_ref().is_none_or(|f| f.2.is_empty()) && crate::desk::accessibility_switched_on() { "\n(這台的無障礙介面原本是關著的,AnyBot 剛把它打開:之後打開的 App 才會有編號,已經開著的 App 要關掉重開。網頁請改用 browser_* 工具。)" } else { "" }; #[cfg(not(target_os = "linux"))] let a11y_note = ""; let (app, window, mut els) = found.unwrap_or_default(); // Only what is on this screen gets a number (a link below the fold was numbered at the // picture's edge, and a click there landed on the edge). let (sw, sh) = mac::screen_points(); els.retain(|e| { let (cx, cy) = (e.x + e.w / 2.0, e.y + e.h / 2.0); sw <= 0.0 || (cx >= 0.0 && cy >= 0.0 && cx < sw && cy < sh) }); // A number is a promise that a click there lands in this app: a control another window // covers (or one on another desktop) gets none. let hidden = cover_check(window, &mut els); // The menu bar's status icons (Tailscale, Wi-Fi, a VPN…) belong to no window in front, so // they had no numbers and a bot cropped and measured screenshots to find them. #[cfg(target_os = "macos")] if controls_allowed() { els.extend(menu_bar_controls()); } // Numbers kept from the last look where the same control is still there (marks::Numbering). static NUMBERS: Mutex> = Mutex::new(None); let (numbered, lines, spots) = { let mut m = NUMBERS.lock().unwrap_or_else(|e| e.into_inner()); crate::marks::number_keeping(&els, picture.is_none(), m.get_or_insert_with(Default::default)) }; *SPOTS.lock().unwrap_or_else(|e| e.into_inner()) = Some((app.clone(), spots)); let mut text = after.clone().map(|n| format!("{n}\n")).unwrap_or_default(); let mut fp = crate::marks::fingerprint(&els); let mut img_b64 = None; if let Some(img) = picture { let (fw, fh) = shot_size(); let mut img = if img.dimensions() != (fw, fh) { image::imageops::resize(&img, fw, fh, image::imageops::FilterType::Triangle) } else { img }; fp ^= crate::marks::picture_fingerprint(&img).rotate_left(1); crate::marks::draw(&mut img, &numbered, scale_here()); let jpeg = encode_jpeg(&img).ok_or("截圖太大了,沒辦法送出")?; img_b64 = Some(base64::engine::general_purpose::STANDARD.encode(&jpeg)); text.push_str(&format!("螢幕截圖({fw}×{fh})。")); } else { text.push_str("(這台現在拍不到畫面:macOS 還沒允許「螢幕與系統錄音」。下面是最前面 App 的內容。)"); } let unchanged = { let mut last = LAST_LOOK.lock().unwrap_or_else(|e| e.into_inner()); let same = pointer && after.is_some() && *last == Some(fp); *last = Some(fp); same }; if unchanged { text.push_str("\n⚠️ 畫面和上一步完全一樣:這一步沒有點到東西。不要再做同一步;換一個編號、換一個方法(網頁用 browser_* 工具,或直接開網址),或用 hand_over_computer 請使用者幫忙。"); } if hidden > 0 { text.push_str(&format!("\n(「{app}」有 {hidden} 個按鈕被別的視窗擋住或不在這個桌面,沒有編號:先 computer_use_open_app 把它叫到前面。)")); } if lines.is_empty() && app.is_empty() { let why = if why.is_empty() { String::new() } else { format!("({})", why.lines().next().unwrap_or("")) }; text.push_str(&format!("\n看不出最前面是哪個 App{why},所以沒有編號:只好照圖上的位置點 x、y。")); } else if lines.is_empty() { text.push_str(&format!("\n「{app}」沒有可以照編號點的東西(有些 App 不提供):只好照圖上的位置點 x、y。")); } else { text.push_str(&format!("\n「{app}」上可以操作的東西(圖上同樣的編號,用 ref 點):\n{}", lines.join("\n"))); } text.push_str(a11y_note); Ok((text, img_b64)) } /// Take the number away from controls whose spot another app's window covers (macOS; elsewhere /// the window system doesn't say). Returns how many. fn cover_check(window: Option, els: &mut [crate::marks::Element]) -> usize { #[cfg(target_os = "macos")] { let Some(pid) = window.and_then(mac::pid_of_window) else { return 0 }; let mut n = 0; for e in els.iter_mut().filter(|e| e.active) { let (x, y) = e.centre(); if mac::pid_on_top_at(x, y).map(|p| p != pid).unwrap_or(false) { e.active = false; n += 1; } } n } #[cfg(not(target_os = "macos"))] { let _ = (window, els); 0 } } /// After a step: wait for the screen to settle, then look. fn observe(note: String, pointer: bool) -> (String, Option) { std::thread::sleep(std::time::Duration::from_millis(SETTLE_MS)); match look(Some(note.clone()), pointer) { Ok(r) => r, Err(e) => (format!("{note}(看不到結果:{e})"), None), } } fn run_inner(name: &str, v: &Value) -> Result<(String, Option), String> { // A control named instead of numbered (「按 4」): its number in the latest look. let mut named = v.clone(); if matches!(name, "computer_use_click" | "computer_use_typing" | "computer_use_scroll" | "computer_use_mouse_move" | "computer_use_batch") { let spots = SPOTS.lock().unwrap_or_else(|e| e.into_inner()).clone().map(|(_, s)| s); crate::marks::names_to_numbers(&mut named, spots.as_ref())?; } let v = &named; 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()); } match name { "computer_use_screenshot" => return look(None, false), "computer_use_zoom" => { let img = capture_screen()?; return zoom(&img, v).map(|(t, j)| (t, Some(j))); } "computer_use_show" => { let (w, h, jpeg) = plain_picture()?; return Ok((format!("{SHOWN_NOTE}({w}×{h})"), Some(base64::engine::general_purpose::STANDARD.encode(&jpeg)))); } "computer_use_check_permissions" => { #[cfg(target_os = "linux")] return Ok((crate::desk::describe().into(), None)); #[allow(unreachable_code)] { let see = mac::screen_capture_preflight(); let control = controls_allowed(); return Ok((format!("螢幕錄製(看畫面):{}\n輔助使用(滑鼠、鍵盤、讀 App):{}{}", if see { "已允許" } else { "還沒允許" }, if control { "已允許" } else { "還沒允許" }, if see && control { "" } else { "\n請使用者到「系統設定 → 隱私權與安全性」打開 AnyBot。" }), None)); } } _ => {} } if !moves_things(name) { return Err(format!("沒有「{name}」這個工具")); } if HELD_BY_PERSON.load(std::sync::atomic::Ordering::SeqCst) { return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作;可以先截圖看看。".into()); } if name != "computer_use_open_app" { need_controls()?; } let note = match name { "computer_use_batch" => batch(v, scale_here())?, _ => act(name, v, scale_here()).map_err(plain_words)?, }; Ok(observe(note, is_pointer_step(name))) } /// agent-desktop's error codes, as words the bot can pass on. fn plain_words(t: String) -> String { if t.contains("PERM_DENIED") { ask_permission("Privacy_Accessibility"); return NO_ACCESSIBILITY.to_string(); } if t.contains("APP_NOT_FOUND") { return format!("找不到這個 App(沒有開著,或名字不對):先用 computer_use_open_app 打開它。({t})"); } t } /// The phone or another viewer acting on this screen as the person: their tap is on the picture /// they were sent (`shot_size()`), so it maps exactly like the bot's coordinates. Not refused /// while the person holds the screen (it is the person), and no picture back (the viewer /// refreshes its own). pub fn person_act(name: &str, v: &Value) -> Result<(), String> { need_controls()?; act(name, v, scale_here()).map(|_| ()) } /// 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 { let v = crate::tools::args_of(args); if let Some(app) = v["app"].as_str().filter(|a| !a.trim().is_empty() && name == "computer_use_open_app") { return Some(app.trim().to_string()); } if !v["ref"].is_null() { return SPOTS.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(app, _)| app.clone()); } let s = scale_here(); let point = match name { "computer_use_click" | "computer_use_mouse_move" | "computer_use_scroll" 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 { 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 { mac::window_at(x, y).map(|w| w.0) } /// The screen as the person sees it (no numbers), at the bot's picture size: for the phone. pub fn plain_picture() -> Result<(u32, u32, Vec), String> { if !mac::screen_capture_preflight() { ask_permission("Privacy_ScreenCapture"); return Err(NO_SCREEN_RECORDING.into()); } let img = capture_screen()?; let (fw, fh) = shot_size(); let img = if img.dimensions() != (fw, fh) { image::imageops::resize(&img, fw, fh, image::imageops::FilterType::Triangle) } else { img }; Ok((fw, fh, encode_jpeg(&img).ok_or("截圖太大了,沒辦法送出")?)) } /// The whole screen as RGB (backing pixels). pub fn capture_screen() -> Result { #[cfg(target_os = "linux")] return crate::desk::capture(); #[allow(unreachable_code)] { // A random tail: two bots screenshotting the same millisecond must not delete each // other's file (pid+time alone collides). let file = std::env::temp_dir().join(format!("anybot-screen-{}-{}-{}.png", std::process::id(), crate::model::now_ms(), crate::httpd::random_hex(4))); 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 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; static kCGWindowNumber: *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, layer, window number). fn all_windows() -> Vec<(String, i32, CGRect, i32, u32)> { let mut out = vec![]; unsafe { // kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements let list = CGWindowListCopyWindowInfo(1 | 16, 0); if list.is_null() { return out; } let num = |d: *const c_void, k: *const c_void| -> i64 { let mut v: i64 = -1; let p = CFDictionaryGetValue(d, k); if !p.is_null() { CFNumberGetValue(p, 4, &mut v as *mut i64 as *mut c_void); } v }; for i in 0..CFArrayGetCount(list) { let d = CFArrayGetValueAtIndex(list, i); let (layer, pid, number) = (num(d, kCGWindowLayer) as i32, num(d, kCGWindowOwnerPID) as i32, num(d, kCGWindowNumber) as u32); 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, layer, number)); } CFRelease(list); } out } /// On-screen windows front to back: (owner app, pid, bounds), normal layer only. fn windows() -> Vec<(String, i32, CGRect)> { all_windows().into_iter().filter(|w| w.3 == 0).map(|(n, p, r, _, _)| (n, p, r)).collect() } /// The app (pid) that owns a window, by its number. pub fn pid_of_window(number: u32) -> Option { all_windows().into_iter().find(|w| w.4 == number).map(|w| w.1) } /// Whose window is on top at a screen point: app windows, floating and modal panels, pop-up /// menus (window levels 0, 3, 8, 101). Not the menu bar, overlays, or the Dock's invisible /// full-screen window (level 20), which lies over everything. pub fn pid_on_top_at(x: f64, y: f64) -> Option { all_windows() .into_iter() .filter(|w| matches!(w.3, 0 | 3 | 8 | 101) && w.2.size.width > 1.0) .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(|w| w.1) } /// 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) { // Points times the backing scale (CGDisplayPixelsWide reports points on Retina, while // screencapture gives backing pixels). let (pw, ph) = screen_points(); let scale = backing_scale(); ((pw * scale) as u32, (ph * scale) as u32) } fn backing_scale() -> f64 { static S: std::sync::OnceLock = 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) }) } #[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); } /// Type text so that any app takes it, whatever the keyboard layout or input method: paste /// it (⌘V) and put the clipboard back. Text sent as key events (the original's /// CUTypedCharacters, key 128 carrying the characters) went into the input method instead /// when one was on — with 注音 every letter vanished while return still arrived, so a /// command typed into a terminal ran empty. Falls back to those key events without a /// clipboard. pub fn type_text(text: &str) { if !paste(text) { type_as_keys(text); } } /// Wait for the app to read the clipboard before it is put back (Electron apps read it a /// moment after ⌘V; estimated). const PASTE_READ_MS: u64 = 350; fn paste(text: &str) -> bool { let Ok(mut cb) = arboard::Clipboard::new() else { return false }; // What the person had there comes back afterwards (text or a picture). let saved_text = cb.get_text().ok(); let saved_image = if saved_text.is_none() { cb.get_image().ok() } else { None }; if cb.set_text(text.to_string()).is_err() { return false; } std::thread::sleep(Duration::from_millis(40)); press(9, FLAG_CMD, KEY_HOLD_SECS); std::thread::sleep(Duration::from_millis(PASTE_READ_MS)); match (saved_text, saved_image) { (Some(t), _) => { let _ = cb.set_text(t); } (None, Some(i)) => { let _ = cb.set_image(i); } _ => { let _ = cb.clear(); } } true } /// CUTypedCharacters.postChunks: each chunk on key 128, down, 0.035 s, up; 0.02 s between chunks. fn type_as_keys(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) } } #[cfg(test)] mod tests { use super::*; #[test] fn menu_bar_icons_are_named_plainly() { assert_eq!(icon_name("MenuBarAgent", "Wi‑Fi,已連接,3格"), "Wi‑Fi,已連接,3格"); assert_eq!(icon_name("Tailscale", ""), "Tailscale"); assert_eq!(icon_name("Tailscale", "Tailscale"), "Tailscale"); assert_eq!(icon_name("LINE", "好友名單"), "LINE:好友名單"); assert_eq!(crate::axread::menu_extra_name("com.apple.menuextra.wifi"), "Wi-Fi"); assert_eq!(crate::axread::menu_extra_name("com.apple.menuextra.clock"), "時鐘"); assert_eq!(crate::axread::menu_extra_name("org.example.thing"), "org.example.thing"); } /// The menu bar's icons get numbers on a Mac (needs 輔助使用 for the test runner): /// `cargo test menu_bar_icons_get_numbers -- --ignored --nocapture`. #[test] #[ignore] #[cfg(target_os = "macos")] fn menu_bar_icons_get_numbers() { let t = std::time::Instant::now(); let els = menu_bar_controls(); println!("{} icons in {} ms", els.len(), t.elapsed().as_millis()); for e in &els { println!(" {} 「{}」 ({:.0},{:.0} {:.0}x{:.0})", e.kind, e.name, e.x, e.y, e.w, e.h); } assert!(!els.is_empty() && els.iter().all(|e| e.active && e.y < 60.0 && !e.name.is_empty())); // The second look within the freshness window is the cached list: instant. let t = std::time::Instant::now(); assert_eq!(menu_bar_controls().len(), els.len()); assert!(t.elapsed().as_millis() < 20); } /// A zoom cuts the asked area from the sharp screen, enlarges it, and says how to map back. #[test] fn a_zoom_is_the_asked_area_sharp_and_bigger() { // A 2048×1152 screen (the bot sees it at 1024×576): a red square at screen (1800..1840, 10..50). let mut img = image::RgbImage::from_pixel(2048, 1152, image::Rgb([255, 255, 255])); for x in 1800..1840 { for y in 10..50 { img.put_pixel(x, y, image::Rgb([255, 0, 0])); } } // In the bot's picture that square is at (900..920, 5..25): zoom the menu-bar strip around it. let (text, b64) = zoom(&img, &json!({"x": 880, "y": 0, "width": 64, "height": 32})).unwrap(); let pic = image::load_from_memory(&base64::engine::general_purpose::STANDARD.decode(b64).unwrap()).unwrap().to_rgb8(); let (w, h) = pic.dimensions(); // 64×32 picture pixels = 128×64 screen pixels, enlarged 4× (the cap) to 512×256. assert_eq!((w, h), (512, 256), "{text}"); // The square's middle (screen 1820, 30) → crop (60, 30) → zoomed (240, 120): red. let p = pic.get_pixel(240, 120); assert!(p[0] > 200 && p[1] < 80, "{p:?}"); assert!(text.contains("(880, 0) 到 (944, 32)") && text.contains("0.125"), "{text}"); // Out of the picture or missing numbers: clamped, or said plainly. assert!(zoom(&img, &json!({"x": 5000, "y": 5000, "width": 10, "height": 10})).is_ok()); assert!(zoom(&img, &json!({"x": 1})).unwrap_err().contains("width")); } #[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![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![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!(key_plan("cmd+a delete").unwrap(), vec![Key::Combo(0, FLAG_CMD), Key::Combo(51, 0)]); assert!(key_plan(" ").is_err()); // Keys by the names models use, and characters without a key typed instead. assert_eq!(key_plan("cmd+grave").unwrap(), vec![Key::Combo(50, FLAG_CMD)]); // A printing key alone is its character (typed, so 注音 can't swallow it). assert_eq!(key_plan("slash").unwrap(), vec![Key::Char("/".into())]); assert_eq!(key_plan("/ @ r return").unwrap(), vec![Key::Char("/".into()), Key::Char("@".into()), Key::Char("r".into()), Key::Combo(36, 0)]); assert!(key_plan("hyper+x").is_err()); } #[test] fn pictures_and_clicks_share_one_size() { // The picture's size depends only on the screen, so a tap or a click maps the same // whoever saw which screenshot last (a 2940×1912 Retina screen of 1470×956 points). let (w, h, _) = fit(2940, 1912); let s = (1470.0 / w as f64, 956.0 / h as f64); let (x, y) = to_screen(512.0, 333.0, s); assert!((x - 735.0).abs() < 0.01 && (y - 478.0).abs() < 1.0); } #[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()); } }