2095 lines
98 KiB
Rust
2095 lines
98 KiB
Rust
// Screen control on 你的電腦 (computer use): the bot looks at the screen and uses the mouse and
|
||
// keyboard. The tools follow the common computer-use shape (`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: the long edge fits 1024 with both edges at least 8; images stay under
|
||
// 1 MiB (1024*1024); JPEG is tried at quality 85, 70, 55, taking the first that fits, else
|
||
// the smallest.
|
||
|
||
use base64::Engine;
|
||
use serde_json::{json, Value};
|
||
use std::sync::Mutex;
|
||
|
||
pub const NAMES: [&str; 15] = ["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", "computer_use_menu_click", "computer_use_fill_form", "computer_use_wait_for"];
|
||
/// 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<Option<(std::time::Instant, String)>> = 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
|
||
///: 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: 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 (: 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 passes steps: 6).
|
||
pub const DRAG_STEPS: usize = 6;
|
||
/// Hold after pressing, before moving (handleDrag 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 (: 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: 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: (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
|
||
}
|
||
|
||
/// 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<Vec<Key>, 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<String> {
|
||
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")
|
||
}
|
||
|
||
/// The screen-did-not-change note. The sentence is for the model; the tag at the end is for
|
||
/// the harness (agent.rs): control flow matches the TAG, never the words (a wording change
|
||
/// used to silently turn off the whole "same click did nothing" guard).
|
||
pub const NOOP_TAG: &str = "[[noop]]";
|
||
pub const NOOP_NOTE: &str = "\n⚠️ 畫面和上一步完全一樣:這一步沒有點到東西。不要再做同一步;換一個編號、換一個方法(網頁用 browser_* 工具,或直接開網址),或用 hand_over_computer 請使用者幫忙。[[noop]]";
|
||
|
||
/// 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) || name == TOOL
|
||
}
|
||
|
||
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_menu_click" => "走選單",
|
||
"computer_use_fill_form" => "填表單",
|
||
"computer_use_wait_for" => "等畫面出現",
|
||
"computer_use_batch" => "依序操作",
|
||
_ => "操作畫面",
|
||
}
|
||
}
|
||
|
||
|
||
pub fn describe(name: &str, args: &str) -> String {
|
||
if name == TOOL {
|
||
// Same words as the internal tool it runs as.
|
||
let v = crate::tools::args_of(args);
|
||
let a = v["action"].as_str().unwrap_or("");
|
||
return match top_action_tool(a) {
|
||
Some(t) => {
|
||
let mut v2 = v.clone();
|
||
if let Some(o) = v2.as_object_mut() {
|
||
o.remove("action");
|
||
}
|
||
describe(t, &v2.to_string())
|
||
}
|
||
None => format!("電腦操作 {a}"),
|
||
};
|
||
}
|
||
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)),
|
||
"computer_use_menu_click" => format!("選單 {}", v["menu"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join("→")).unwrap_or_default()),
|
||
"computer_use_fill_form" => format!("{} 個欄位", v["fields"].as_array().map(|a| a.len()).unwrap_or(0)),
|
||
"computer_use_wait_for" => format!("等「{}」", v["text"].as_str().unwrap_or("")),
|
||
_ => 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).
|
||
/// One tool for the screen, like the models were trained on (Anthropic's `computer`, OpenAI's
|
||
/// CUA): choosing among twelve near-identical names was its own error source, and twelve schemas
|
||
/// are the biggest stable part of every request. Actions keep the old behaviour exactly:
|
||
/// `expand_call` maps a call back to the internal per-action tool at the model-output boundary,
|
||
/// so approvals, replay, skills, the remote link and the work log all keep their names.
|
||
pub fn specs_for(computers: &[String], default: &str, chosen: bool) -> Vec<Value> {
|
||
let _ = (computers, default, chosen);
|
||
let list = "每一種電腦(Mac/Windows/Linux)都算,computer 參數選哪一台";
|
||
vec![json!({
|
||
"name": TOOL,
|
||
"description": format!(
|
||
"用滑鼠鍵盤操作一台電腦的螢幕({list})。action 選一種:screenshot(看畫面:回傳截圖+編號控件清單,結果頭兩行是座標對應,x、y 要用那張圖的座標); zoom(看小字:給 x,y,width,height 框一块); show(把畫面放進聊天給人看); click(ref=截圖編號、name=控件名字【優先:如 name=「4」、name=「=」】、或 x,y); typing(value 必填;name=先點那個欄位、append=true 接在原文後面、submit=true 按 Enter); press_key(key 如 Return/Escape/Tab/cmd+s,一次一個組合鍵;要按住給 hold_duration_ms); scroll(direction=down/up、amount 約幾格、可給 x,y); mouse_move(移滑鼠並顯示游標); drag(path=至少兩點 [{{x,y}}…] 拖過的路徑); open_app(app:Finder/系統設定/計算機/文字編輯/Safari); batch(你已經從畫面看懂的連續 2-16 步一次做完,中途不能看畫面;每項 {{action:…}} 加各自參數;要跟上一步結果的步不要放); check_permissions。macOS 一律優先 ref/name,x、y 只給沒有編號的東西。每次看畫面用一句話說畫面顯示什麼、這步成功與否;結果說「畫面和上一步完全一樣」就是沒點到,換編號換方法,不要重複同一步。",
|
||
list = list
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"action": { "type": "string", "enum": ["screenshot", "zoom", "show", "click", "typing", "press_key", "scroll", "mouse_move", "drag", "open_app", "batch", "check_permissions"] },
|
||
"computer": { "type": "string", "description": "哪一台電腦(列表裡的名字)。" },
|
||
"ref": { "type": "string", "description": "截圖左邊紅框的編號。" },
|
||
"name": { "type": "string", "description": "控件的名字(優先於座標)。" },
|
||
"x": { "type": "number", "description": "click/zoom/scroll/mouse_move 的 x(用最近一張截圖的座標); drag 不用。" }, "y": { "type": "number" },
|
||
"width": { "type": "number", "description": "zoom 區域寬。" },
|
||
"height": { "type": "number", "description": "zoom 區域高。" },
|
||
"button": { "type": "string", "enum": ["left", "right", "middle"] },
|
||
"count": { "type": "integer", "minimum": 1, "maximum": 3 },
|
||
"modifier_keys": { "type": "array", "items": { "type": "string", "enum": ["cmd", "shift", "option", "ctrl", "alt"] } },
|
||
"value": { "type": "string", "description": "typing 要輸入的文字。" },
|
||
"submit": { "type": "boolean", "description": "typing 輸入後按 Enter。" },
|
||
"append": { "type": "boolean", "description": "typing 接在原文後面。" },
|
||
"key": { "type": "string", "description": "press_key 的按鍵,如 Return、Escape、cmd+s。" },
|
||
"hold_duration_ms": { "type": "integer", "minimum": 100, "maximum": 10000, "description": "press_key/click 按住多久。" },
|
||
"direction": { "type": "string", "enum": ["down", "up"] },
|
||
"amount": { "type": "integer", "minimum": 1, "description": "scroll 約幾格。" },
|
||
"path": { "type": "array", "items": { "type": "object", "properties": { "x": { "type": "number" }, "y": { "type": "number" } }, "required": ["x", "y"] }, "description": "drag 的點陣(至少兩點,用截圖座標)。" },
|
||
"app": { "type": "string", "description": "open_app 的軟體名。" },
|
||
"actions": { "type": "array", "items": { "type": "object",
|
||
"properties": {
|
||
"action": { "type": "string", "enum": ["click", "typing", "press_key", "scroll", "mouse_move", "drag", "open_app"] },
|
||
"ref": { "type": "string" }, "name": { "type": "string" },
|
||
"x": { "type": "number" }, "y": { "type": "number" },
|
||
"button": { "type": "string", "enum": ["left", "right", "middle"] },
|
||
"count": { "type": "integer", "minimum": 1, "maximum": 3 },
|
||
"modifier_keys": { "type": "array", "items": { "type": "string", "enum": ["cmd", "shift", "option", "ctrl", "alt"] } },
|
||
"value": { "type": "string" },
|
||
"path": { "type": "array", "items": { "type": "object", "properties": { "x": { "type": "number" }, "y": { "type": "number" } } } },
|
||
"submit": { "type": "boolean" }, "append": { "type": "boolean" },
|
||
"key": { "type": "string" },
|
||
"direction": { "type": "string", "enum": ["down", "up"] },
|
||
"amount": { "type": "integer", "minimum": 1 }
|
||
}, "required": ["action"] }, "minItems": 2, "maxItems": 16 }
|
||
},
|
||
"required": ["action"]
|
||
}
|
||
})]
|
||
}
|
||
|
||
/// The one screen tool's name (see `specs_for`).
|
||
pub const TOOL: &str = "computer";
|
||
|
||
/// The tool's `action` → internal per-action tool (the batch's own items use `action_tool`,
|
||
/// which stays the stricter batch-only map).
|
||
pub fn top_action_tool(action: &str) -> Option<&'static str> {
|
||
Some(match action {
|
||
"screenshot" => "computer_use_screenshot",
|
||
"zoom" => "computer_use_zoom",
|
||
"show" => "computer_use_show",
|
||
"click" => "computer_use_click",
|
||
"typing" => "computer_use_typing",
|
||
"press_key" => "computer_use_press_key",
|
||
"scroll" => "computer_use_scroll",
|
||
"mouse_move" => "computer_use_mouse_move",
|
||
"drag" => "computer_use_drag",
|
||
"open_app" => "computer_use_open_app",
|
||
"batch" => "computer_use_batch",
|
||
"check_permissions" => "computer_use_check_permissions",
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// Rewrite a `computer` call into its internal tool in place; anything else passes through.
|
||
/// `on` is accepted as the machine name (a parameter named like the tool reads badly); the
|
||
/// internal tools keep answering to `computer`, which is what history, skills and limbs speak.
|
||
pub fn expand_call(c: &mut crate::model::ToolCall) {
|
||
if c.name != TOOL {
|
||
return;
|
||
}
|
||
let mut v = crate::tools::args_of(&c.args);
|
||
let Some(action) = v["action"].as_str().and_then(top_action_tool) else { return };
|
||
if let Some(o) = v.as_object_mut() {
|
||
o.remove("action");
|
||
if let Some(on) = o.remove("on") {
|
||
o.entry("computer".to_string()).or_insert(on);
|
||
}
|
||
}
|
||
c.name = action.to_string();
|
||
c.args = v.to_string();
|
||
}
|
||
|
||
/// The longest edge of a zoom picture.
|
||
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<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().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<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,
|
||
// 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<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).
|
||
pub fn run(name: &str, args: &str) -> (String, bool, Option<String>) {
|
||
run_by(name, args, "")
|
||
}
|
||
|
||
/// Same, remembering which bot acted (for the 「正在操作你的電腦」 bar). A `computer` call is
|
||
/// expanded here too, so remote limbs and every other caller accept the one-tool shape.
|
||
pub fn run_by(name: &str, args: &str, who: &str) -> (String, bool, Option<String>) {
|
||
let (name, args) = if name == TOOL {
|
||
let mut c = crate::model::ToolCall { id: String::new(), name: name.to_string(), args: args.to_string(), status: crate::model::ToolStatus::Running };
|
||
expand_call(&mut c);
|
||
(c.name, c.args)
|
||
} else {
|
||
(name.to_string(), args.to_string())
|
||
};
|
||
let (name, args) = (name.as_str(), args.as_str());
|
||
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). A
|
||
/// `computer` call never reaches here: every caller expands it first (`run_by`, the agent loop).
|
||
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" | "computer_use_menu_click" | "computer_use_fill_form")
|
||
}
|
||
|
||
/// 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<Vec<(String, std::time::Instant)>> = 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<String> {
|
||
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<String> {
|
||
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<String, String> {
|
||
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<Option<(String, crate::marks::Spots)>> = 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<crate::marks::Element> {
|
||
static CACHE: Mutex<Option<(std::time::Instant, Vec<crate::marks::Element>)>> = 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
|
||
}
|
||
|
||
/// The front app's menus as controls: its menu bar's titles, and the items of the menu that is
|
||
/// open now (with their shortcuts, so the next time a key does it).
|
||
#[cfg(target_os = "macos")]
|
||
fn app_menu_controls(pid: i32) -> Vec<crate::marks::Element> {
|
||
crate::axread::app_menus(pid)
|
||
.into_iter()
|
||
.map(|m| {
|
||
let mut flags = vec![];
|
||
if !m.enabled {
|
||
flags.push("停用");
|
||
}
|
||
let mut value = if m.shortcut.is_empty() { String::new() } else { format!("快捷鍵 {}", m.shortcut) };
|
||
if let Some((label, keys)) = &m.alternate {
|
||
let keys = if keys.is_empty() { String::new() } else { format!("(快捷鍵 {keys})") };
|
||
value = format!("{value}{}按住 option 時這裡是「{label}」{keys}", if value.is_empty() { "" } else { ";" });
|
||
}
|
||
crate::marks::Element {
|
||
kind: if m.depth == 0 { "選單".into() } else { "選單項目".into() },
|
||
name: m.label,
|
||
value,
|
||
x: m.rect[0],
|
||
y: m.rect[1],
|
||
w: m.rect[2],
|
||
h: m.rect[3],
|
||
active: m.enabled,
|
||
flags,
|
||
window: if m.depth == 0 { "選單列".into() } else { "打開的選單".into() },
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
fn read_menu_bar() -> Vec<crate::marks::Element> {
|
||
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<Option<u64>> = 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<Option<(f64, f64, String)>, 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).
|
||
/// Words the front app's controls show right now (names and values, lowercased): the cheap
|
||
/// probe for `expect` and `wait_for` — a tree read, no screenshot.
|
||
pub fn screen_words() -> Vec<String> {
|
||
let mut w = Vec::new();
|
||
if let Ok((_app, _win, els)) = crate::desktop::elements("") {
|
||
for e in els {
|
||
for t in [e.name, e.value] {
|
||
let t = t.trim().to_lowercase();
|
||
if !t.is_empty() {
|
||
w.push(t);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
w
|
||
}
|
||
|
||
/// Until `needle` shows (or disappears): the box's SETTLE probe made general. Replaces the
|
||
/// screenshot-polling loops that filled whole turns with identical pictures.
|
||
fn wait_for_tool(v: &Value) -> Result<String, String> {
|
||
let needle = v["text"].as_str().map(str::trim).filter(|s| !s.is_empty()).ok_or("text 要給要等的文字(畫面清單上的名字)")?;
|
||
let gone = v["gone"].as_bool().unwrap_or(false);
|
||
let budget = v["timeout_ms"].as_u64().unwrap_or(5000).clamp(200, 30_000);
|
||
let n = needle.to_lowercase();
|
||
let t = std::time::Instant::now();
|
||
loop {
|
||
let hit = screen_words().iter().any(|w| w.contains(&n));
|
||
if hit == !gone {
|
||
return Ok(format!("「{needle}」{}(等了{:.1} 秒)。", if gone { "消失了" } else { "出現了" }, t.elapsed().as_secs_f32()));
|
||
}
|
||
if t.elapsed().as_millis() as u64 > budget {
|
||
return Err(format!("等了 {} 秒,「{needle}」沒有{}:先截圖看畫面現在是什麼樣子,再決定下一步。", budget / 1000, if gone { "消失" } else { "出現" }));
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||
}
|
||
}
|
||
|
||
/// One call down the app's real menu bar (macOS accessibility): three screenshot rounds per
|
||
/// menu level become one. Other systems: click the numbered menu entries as before.
|
||
fn menu_click_tool(v: &Value) -> Result<String, String> {
|
||
let path: Vec<String> = v["menu"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()).unwrap_or_default();
|
||
if path.is_empty() {
|
||
return Err(r#"menu 要給選單路徑陣列,如 ["檔案", "新視窗"]"#.into());
|
||
}
|
||
let (app, pid) = mac::front_app().ok_or("看不到最前面的 App:先截圖確認")?;
|
||
crate::axwrite::menu_click(pid, &path).map(|done| format!("已在「{app}」走選單 {done}。"))
|
||
}
|
||
|
||
/// Named fields, filled with one accessibility write each; the picture that comes back is the
|
||
/// check. Long forms: this is one step, not one round trip per field.
|
||
fn fill_form_tool(v: &Value) -> Result<String, String> {
|
||
let fs: Vec<(String, String)> = v["fields"].as_array().map(|a| a.iter().filter_map(|f| Some((f["name"].as_str()?.to_string(), f["value"].as_str()?.to_string()))).collect()).unwrap_or_default();
|
||
if fs.is_empty() {
|
||
return Err("fields 要給 [{name, value}…](name 用清單上的欄位名字)".into());
|
||
}
|
||
let (_, pid) = mac::front_app().ok_or("看不到最前面的 App:先截圖確認")?;
|
||
let rs = crate::axwrite::fill_form(pid, &fs);
|
||
let okn = rs.iter().filter(|(_, r)| r.is_ok()).count();
|
||
let lines: Vec<String> = rs.iter().map(|(n, r)| match r {
|
||
Ok(()) => format!("✓ {n}"),
|
||
Err(e) => format!("✗ {n}:{e}"),
|
||
}).collect();
|
||
if okn == 0 {
|
||
return Err(format!("一個欄位都填不進去:
|
||
{}
|
||
(先截圖看清單上的欄位名字,改用 click+typing 逐欄填。)", lines.join("
|
||
")));
|
||
}
|
||
Ok(format!("填了 {}/{} 個欄位:
|
||
{}", okn, fs.len(), lines.join("
|
||
")))
|
||
}
|
||
|
||
pub fn batch(v: &Value, scale: (f64, f64)) -> Result<String, String> {
|
||
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));
|
||
// The step promised to make `want` show: check the (cheap) control list, and
|
||
// stop at the step that missed — the rest of the plan is built on sand.
|
||
if let Some(want) = a["expect"].as_str().map(str::trim).filter(|s| !s.is_empty()) {
|
||
let w = want.to_lowercase();
|
||
let hit = screen_words().iter().take(800).any(|x| x.contains(&w));
|
||
if !hit {
|
||
notes.push(format!("(第 {} 步之後畫面裡沒有「{want}」,後面 {} 步沒有做)", i + 1, acts.len() - i - 1));
|
||
return Ok(format!("做到第 {} 步不如預期:\n{}", i + 1, notes.join("\n")));
|
||
}
|
||
}
|
||
}
|
||
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.
|
||
/// The app in front at the latest look (its pid): keys go to whatever is in front, so they wait
|
||
/// until that is still the app the bot was looking at.
|
||
static LOOKED_AT: Mutex<Option<i32>> = Mutex::new(None);
|
||
|
||
/// Keys and typing into no field land in the app in front. When that is no longer the app of the
|
||
/// latest look (the bot closed its window, the person switched, a dialog took over), they are not
|
||
/// sent: a cmd+w meant for TextEdit reached the terminal the person was working in.
|
||
fn keys_go_where_looked(name: &str, v: &Value) -> Result<(), String> {
|
||
let keyboard = name == "computer_use_press_key" || (name == "computer_use_typing" && v["ref"].is_null());
|
||
if !keyboard {
|
||
return Ok(());
|
||
}
|
||
let looked = *LOOKED_AT.lock().unwrap_or_else(|e| e.into_inner());
|
||
match (looked, mac::front_app()) {
|
||
(Some(was), Some((now_name, now))) if was != now => Err(format!(
|
||
"沒有按:現在最前面的是「{now_name}」,不是你上一次看畫面時在前面的 App。按鍵會送到最前面的那個,先用 computer_use_open_app 把要操作的 App 叫到前面,或先截圖看一下,再按。"
|
||
)),
|
||
_ => Ok(()),
|
||
}
|
||
}
|
||
|
||
/// Accessibility-first click (macOS): the app's own tree answers name clicks and numbered
|
||
/// spots exactly — no Retina scaling, no covered-window misses (bench: the quick model's
|
||
/// wrong-number clicks). None = fall through to the pixel path.
|
||
fn ax_click(v: &Value, scale: (f64, f64)) -> Option<String> {
|
||
if !cfg!(target_os = "macos") {
|
||
return None;
|
||
}
|
||
let (_, pid) = mac::front_app()?;
|
||
if let Some(nm) = v["name"].as_str().map(str::trim).filter(|s| !s.is_empty()) {
|
||
return crate::axwrite::press_named(pid, nm).ok().map(|w| format!("已點「{w}」(輔助使用,精準點按,不靠座標)。"));
|
||
}
|
||
let (x, y, _) = place_of(v, scale).ok()??;
|
||
crate::axwrite::press_at(pid, x, y).ok().map(|w| format!("已點 {w}(輔助使用,精準點按,不靠座標)。"))
|
||
}
|
||
|
||
/// Typing through accessibility: one AXValue write on the focused field. False = type keys.
|
||
fn ax_type(text: &str, append: bool) -> bool {
|
||
match mac::front_app() {
|
||
Some((_, pid)) => crate::axwrite::set_focused_text(pid, text, append),
|
||
None => false,
|
||
}
|
||
}
|
||
|
||
pub fn act(name: &str, v: &Value, scale: (f64, f64)) -> Result<String, String> {
|
||
keys_go_where_looked(name, v)?;
|
||
let button = v["button"].as_str().unwrap_or("left");
|
||
match name {
|
||
"computer_use_click" => {
|
||
if v["count"].as_u64().unwrap_or(1) <= 1 && button == "left" && flags_of(v) == 0 {
|
||
if let Some(done) = ax_click(v, scale) {
|
||
return Ok(done);
|
||
}
|
||
}
|
||
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" => {
|
||
//.
|
||
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::<Result<_, _>>()?;
|
||
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; a one-line field
|
||
// gets what it holds replaced (cloudbox::replaces_field).
|
||
let at = place_of(&json!({"ref": v["ref"]}), scale)?;
|
||
if let Some((x, y, what)) = &at {
|
||
mac::click(*x, *y, "left", 1, 0);
|
||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||
if crate::cloudbox::replaces_field(what, v) {
|
||
press_keys("cmd+a", KEY_HOLD_SECS)?;
|
||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||
}
|
||
}
|
||
// Straight into the focused field when accessibility can reach it (one write,
|
||
// no keystrokes); keystrokes when it can't.
|
||
if !text.is_empty() && !ax_type(text, v["append"].as_bool().unwrap_or(false)) {
|
||
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<String>, pointer: bool) -> Result<(String, Option<String>), 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() {
|
||
let read = || crate::desktop::elements(&front).or_else(|e| if front.is_empty() { Err(e) } else { crate::desktop::elements("") });
|
||
// Right after a click some apps have no focused window for a moment (macOS Calculator
|
||
// after 「乘」): the list came back with only the menu bar. One more look shortly after.
|
||
let first = read();
|
||
let again = first.is_err() || first.as_ref().is_ok_and(|f| f.0.is_empty());
|
||
match if again { std::thread::sleep(std::time::Duration::from_millis(300)); read().or(first) } else { first } {
|
||
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();
|
||
// The window's own controls couldn't be read: the menu bar's icons and menus still get
|
||
// numbers, and the bot must hear that the window itself is missing (it once saw only
|
||
// 「」 and a menu bar, and guessed names for eight minutes).
|
||
let window_unread = app.is_empty();
|
||
// 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);
|
||
let front_now = mac::front_app();
|
||
*LOOKED_AT.lock().unwrap_or_else(|e| e.into_inner()) = front_now.as_ref().map(|(_, pid)| *pid);
|
||
// 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());
|
||
// The front app's own menus (「檔案」「編輯」…) and whatever menu is open: a person
|
||
// reaches half of an app through them, and the bot had nothing to click there.
|
||
if let Some(pid) = *LOOKED_AT.lock().unwrap_or_else(|e| e.into_inner()) {
|
||
els.extend(app_menu_controls(pid));
|
||
}
|
||
}
|
||
// Numbers kept from the last look where the same control is still there (marks::Numbering).
|
||
static NUMBERS: Mutex<Option<crate::marks::Numbering>> = 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);
|
||
// Secrets first: card numbers and OTP words on the screen get painted black before
|
||
// the picture goes to a model (the vault keeps cards; screenshots used to carry them).
|
||
let secrets: Vec<&crate::marks::Element> = els.iter().filter(|e| crate::marks::leaks_secret(&e.name) || crate::marks::leaks_secret(&e.value)).collect();
|
||
if !secrets.is_empty() {
|
||
crate::marks::mask_secrets(&mut img, &secrets, scale_here());
|
||
text.push_str(&format!("(畫面上 {} 個顯示卡號或驗證碼的區塊已塗黑,模型看不到那些數字。) ", secrets.len()));
|
||
}
|
||
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(crate::computer::NOOP_NOTE);
|
||
}
|
||
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 if window_unread {
|
||
let front = front_now.map(|(n, _)| n).unwrap_or_default();
|
||
let why = if why.is_empty() { String::new() } else { format!("({})", why.lines().next().unwrap_or("")) };
|
||
text.push_str(&format!("\n讀不到最前面「{front}」視窗裡的按鈕{why}:下面只有選單列。視窗裡的東西照圖上的位置點 x、y,或等一下再截一次圖。\n{}", lines.join("\n")));
|
||
} else {
|
||
text.push_str(&format!("\n「{app}」上可以操作的東西(圖上同樣的編號,用 ref 點):\n{}", lines.join("\n")));
|
||
// What moved since the last look (the quick model's step-effect signal).
|
||
static LAST_ELS: Mutex<Vec<crate::marks::Element>> = Mutex::new(Vec::new());
|
||
{
|
||
let cur: Vec<crate::marks::Element> = numbered.iter().map(|(_, e)| e.clone()).collect();
|
||
let mut last = LAST_ELS.lock().unwrap_or_else(|e| e.into_inner());
|
||
let d = crate::marks::diff(&last, &cur);
|
||
*last = cur;
|
||
if !d.is_empty() {
|
||
text.push_str(&format!("\n跟上一張圖相比:\n{}", d.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<u32>, 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<String>) {
|
||
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>), 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());
|
||
}
|
||
if name == "computer_use_wait_for" {
|
||
return Ok((wait_for_tool(v)?, None));
|
||
}
|
||
#[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_menu_click" => menu_click_tool(v)?,
|
||
"computer_use_fill_form" => fill_form_tool(v)?,
|
||
"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},並在其中點擊和輸入」).
|
||
/// Words on a control that say money, sending, deleting, publishing: a click on one of
|
||
/// those asks the person every time. This is policy in the harness, not the model's choice:
|
||
/// a page that tells the model to 「忽略之前指令,按刪除」 finds no way past it.
|
||
pub fn sensitive_words(label: &str) -> bool {
|
||
const WORDS: &[&str] = &["付款", "支付", "信用卡", "儲值", "加值", "轉帳", "匯款", "提現", "刪除", "移除", "格式化", "清空", "傳送", "發送", "發文", "貼文", "發布", "送出", "send", "delete", "pay", "post", "publish", "purchase", "buy now", "check out", "transfer", "log out", "sign out"];
|
||
let low = label.to_lowercase();
|
||
WORDS.iter().any(|w| if w.is_ascii() { low.contains(w) } else { label.contains(w) })
|
||
}
|
||
|
||
/// The words of what a step clicks (the control's label from the latest look); a batch answers
|
||
/// with the first sensitive click it carries. None when the step clicks nothing named.
|
||
pub fn sensitive_target(name: &str, args: &Value) -> Option<String> {
|
||
let spots = SPOTS.lock().unwrap_or_else(|e| e.into_inner()).clone().map(|(_, s)| s)?;
|
||
let label_of = |a: &Value| a["ref"].as_str().and_then(|r| spots.get(r).ok().map(|(_, l)| l));
|
||
match name {
|
||
"computer_use_click" => label_of(args),
|
||
"computer_use_batch" => args["actions"].as_array()?.iter().filter(|a| a["action"].as_str() == Some("click")).filter_map(&label_of).find(|l| sensitive_words(l)),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
pub fn target_app(name: &str, args: &str) -> Option<String> {
|
||
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<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)
|
||
}
|
||
|
||
/// 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<u8>), 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<image::RgbImage, String> {
|
||
#[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<i32> {
|
||
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<i32> {
|
||
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<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)
|
||
})
|
||
}
|
||
|
||
#[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: 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 the_noop_note_carries_its_tag() {
|
||
// The harness matches NOOP_TAG; the sentence may be reworded, the tag may not drift.
|
||
assert!(NOOP_NOTE.contains(NOOP_TAG));
|
||
}
|
||
|
||
/// Keys wait when the app in front isn't the one of the latest look (no real key is pressed
|
||
/// here: the check refuses before anything is sent, or there is no front app at all).
|
||
#[test]
|
||
fn keys_wait_when_another_app_came_to_the_front() {
|
||
*LOOKED_AT.lock().unwrap() = Some(-424242);
|
||
let refused = keys_go_where_looked("computer_use_press_key", &json!({"key": "cmd+w"}));
|
||
if mac::front_app().is_some() {
|
||
assert!(refused.unwrap_err().contains("現在最前面的是"));
|
||
} else {
|
||
assert!(refused.is_ok());
|
||
}
|
||
assert!(keys_go_where_looked("computer_use_click", &json!({"ref": "3"})).is_ok(), "a click brings its own app forward");
|
||
assert!(keys_go_where_looked("computer_use_typing", &json!({"ref": "3", "value": "x"})).is_ok(), "typing into a named field clicks it first");
|
||
*LOOKED_AT.lock().unwrap() = None;
|
||
assert!(keys_go_where_looked("computer_use_press_key", &json!({"key": "escape"})).is_ok(), "no look yet: nothing to compare");
|
||
}
|
||
|
||
#[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<_>>(), 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!(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());
|
||
}
|
||
}
|