Typing into a one-line field replaces it, a named control beats stray coordinates, and a box left by a hard stop starts on the second try

- Typed into the middle of Chromium's 「about:blank」, an address came out as
  chrome://blankchrome//settings/search, and every step after it went wrong. Typing into a
  field by name or number now selects a one-line field's text first (ctrl+a / cmd+a) so the
  text replaces it; append=true keeps it. Text areas are left alone.
- A model sending name plus x: 0, y: 0 had its click land in the screen's corner: a name (or
  number) now always wins over coordinates.
- After the VM went down hard, the box's first start failed in gVisor ("mount source already
  has a filestore file … repeated submounts are not supported") and the second worked: waking
  or restarting the box tries once more on exactly that error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha5F5gJt93t3yEx3XcS
This commit is contained in:
王性驊 2026-11-09 10:19:18 +08:00
parent be790f36ba
commit 9e8135ceda
3 changed files with 46 additions and 8 deletions

View File

@ -595,6 +595,21 @@ fn docker_prefix() -> String {
} }
} }
/// Start (or restart) the box, once more when gVisor trips over what an unclean stop left
/// behind: after the VM or the computer went down hard, the first start fails with "mount source
/// already has a filestore file … repeated submounts are not supported" and clears it itself,
/// and the second one works.
fn start_once_more(args: &[&str]) -> Result<String, String> {
match dk(args) {
Err(e) if e.contains("filestore") || e.contains("repeated submounts") => {
log("上次沒有正常關機,留下的暫存檔擋住了開機,再試一次…");
std::thread::sleep(Duration::from_secs(2));
dk(args)
}
r => r,
}
}
/// Run docker; Ok(stdout) or Err(stderr). /// Run docker; Ok(stdout) or Err(stderr).
fn dk(args: &[&str]) -> Result<String, String> { fn dk(args: &[&str]) -> Result<String, String> {
// Never block forever on the daemon: a hung docker daemon used to pile up a new stuck // Never block forever on the daemon: a hung docker daemon used to pile up a new stuck
@ -1060,7 +1075,7 @@ pub fn ensure_running() -> Result<(), String> {
set_run(Run::Starting("喚醒中")); set_run(Run::Starting("喚醒中"));
log("喚醒電腦…"); log("喚醒電腦…");
let st = dk(&["inspect", "-f", "{{.State.Status}}", container()]).unwrap_or_default(); let st = dk(&["inspect", "-f", "{{.State.Status}}", container()]).unwrap_or_default();
let woke = if st == "paused" { dk(&["unpause", container()]) } else { dk(&["start", container()]) }; let woke = if st == "paused" { dk(&["unpause", container()]) } else { start_once_more(&["start", container()]) };
woke.map_err(|e| format!("叫不醒電腦:{e}")).and_then(|_| wait_ready(BOOT_WAIT_MS)) woke.map_err(|e| format!("叫不醒電腦:{e}")).and_then(|_| wait_ready(BOOT_WAIT_MS))
} }
Run::Starting(_) => { Run::Starting(_) => {
@ -1072,7 +1087,7 @@ pub fn ensure_running() -> Result<(), String> {
// A broken container: start it again. // A broken container: start it again.
log(&format!("電腦狀態異常({e}),重新啟動…")); log(&format!("電腦狀態異常({e}),重新啟動…"));
set_run(Run::Starting("連線中")); set_run(Run::Starting("連線中"));
dk(&["restart", container()]).map_err(|e| e.to_string()).and_then(|_| wait_ready(BOOT_WAIT_MS)) start_once_more(&["restart", container()]).map_err(|e| e.to_string()).and_then(|_| wait_ready(BOOT_WAIT_MS))
} }
}; };
match &result { match &result {
@ -1752,11 +1767,17 @@ pub fn box_script(name: &str, v: &Value, scale: (f64, f64)) -> Result<(String, S
return Err("請給要打的字".into()); return Err("請給要打的字".into());
} }
let mut script = String::new(); let mut script = String::new();
// Into a field by its number: click it first, like a person would. // Into a field by its number: click it first, like a person would. A one-line field
// (an address bar, a search box) gets what it holds selected first, so the text
// replaces it: typed into the middle of 「about:blank」 the address became garbage.
// ("append": true keeps what is there.)
let field = match by_number(v) { let field = match by_number(v) {
Some(r) => { Some(r) => {
let ((x, y), what) = r?; let ((x, y), what) = r?;
script.push_str(&format!("xdotool mousemove {x} {y} sleep 0.03 click 1 sleep 0.15")); script.push_str(&format!("xdotool mousemove {x} {y} sleep 0.03 click 1 sleep 0.15"));
if replaces_field(&what, v) {
script.push_str(" && xdotool key --clearmodifiers ctrl+a sleep 0.05");
}
format!("在 {what}") format!("在 {what}")
} }
None => String::new(), None => String::new(),
@ -1801,6 +1822,12 @@ pub fn box_script(name: &str, v: &Value, scale: (f64, f64)) -> Result<(String, S
/// Type text into the window in front: short plain text key by key; anything else (another /// Type text into the window in front: short plain text key by key; anything else (another
/// language, or long text, which key by key could outlast the step's 30 s and then get typed /// language, or long text, which key by key could outlast the step's 30 s and then get typed
/// twice when the bot tried again) pasted through the clipboard. /// twice when the bot tried again) pasted through the clipboard.
/// Typing into this control replaces what it holds: a one-line field (輸入框), unless the step
/// asks to add to it. A document's text area is left alone (selecting all would replace a file).
pub fn replaces_field(what: &str, v: &Value) -> bool {
what.starts_with("輸入框") && !v["append"].as_bool().unwrap_or(false)
}
fn type_script(text: &str) -> String { fn type_script(text: &str) -> String {
let q = crate::tools::sh_quote(text); let q = crate::tools::sh_quote(text);
if text.is_ascii() && text.chars().count() <= TYPE_KEYS_MAX_CHARS { if text.is_ascii() && text.chars().count() <= TYPE_KEYS_MAX_CHARS {

View File

@ -295,8 +295,8 @@ pub fn specs_for(computers: &[String], default: &str, chosen: bool) -> Vec<Value
"parameters": {"type": "object", "properties": {}}}), "parameters": {"type": "object", "properties": {}}}),
json!({"name": "computer_use_click", "description": "Click a control: by its name as the latest list shows it (name, e.g. 4, Close, 自動更新 — easiest, no number to look up), or by its number (ref) when two share a name. Only for something with neither (a spot in a picture or game): x, y in the picture's pixels. Returns the next picture.", json!({"name": "computer_use_click", "description": "Click a control: by its name as the latest list shows it (name, e.g. 4, Close, 自動更新 — easiest, no number to look up), or by its number (ref) when two share a name. Only for something with neither (a spot in a picture or game): x, y in the picture's pixels. Returns the next picture.",
"parameters": {"type": "object", "properties": {"ref": rf, "name": named, "x": {"type": "number"}, "y": {"type": "number"}, "button": {"type": "string", "enum": ["left", "right", "middle"]}, "count": {"type": "integer", "description": "2 for a double click"}, "modifier_keys": mods}}}), "parameters": {"type": "object", "properties": {"ref": rf, "name": named, "x": {"type": "number"}, "y": {"type": "number"}, "button": {"type": "string", "enum": ["left", "right", "middle"]}, "count": {"type": "integer", "description": "2 for a double click"}, "modifier_keys": mods}}}),
json!({"name": "computer_use_typing", "description": "Type text (any language) into a field by its number (ref; it is clicked first), or where the cursor is. submit=true presses return after it (send, search, run a command). Returns the next picture.", json!({"name": "computer_use_typing", "description": "Type text (any language) into a field by its name or number (it is clicked first; a one-line field's old text is replaced, as if selected first — append=true adds to it instead), or where the cursor is. submit=true presses return after it (send, search, open the address). Returns the next picture.",
"parameters": {"type": "object", "properties": {"value": {"type": "string"}, "ref": rf, "name": named, "submit": {"type": "boolean"}}, "required": ["value"]}}), "parameters": {"type": "object", "properties": {"value": {"type": "string"}, "ref": rf, "name": named, "submit": {"type": "boolean"}, "append": {"type": "boolean", "description": "Keep the field's text and add to it"}}, "required": ["value"]}}),
json!({"name": "computer_use_press_key", "description": "Press a key or shortcut in the app in front: return, tab, escape, delete, up, slash, cmd+c, cmd+shift+t, cmd+space. Several in order: separate with spaces (\"cmd+a delete\"). On a Linux computer cmd means ctrl. Returns the next picture.", json!({"name": "computer_use_press_key", "description": "Press a key or shortcut in the app in front: return, tab, escape, delete, up, slash, cmd+c, cmd+shift+t, cmd+space. Several in order: separate with spaces (\"cmd+a delete\"). On a Linux computer cmd means ctrl. Returns the next picture.",
"parameters": {"type": "object", "properties": {"key": {"type": "string"}, "hold_duration_ms": {"type": "integer"}}, "required": ["key"]}}), "parameters": {"type": "object", "properties": {"key": {"type": "string"}, "hold_duration_ms": {"type": "integer"}}, "required": ["key"]}}),
json!({"name": "computer_use_scroll", "description": "Scroll a list or page: on a control by number (ref), or at x, y, or where the pointer is. Returns the next picture.", json!({"name": "computer_use_scroll", "description": "Scroll a list or page: on a control by number (ref), or at x, y, or where the pointer is. Returns the next picture.",
@ -857,11 +857,16 @@ pub fn act(name: &str, v: &Value, scale: (f64, f64)) -> Result<String, String> {
if text.is_empty() && !submit { if text.is_empty() && !submit {
return Err("請給要打的字".into()); return Err("請給要打的字".into());
} }
// Into a field by its number: click it first, like a person would. // 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)?; let at = place_of(&json!({"ref": v["ref"]}), scale)?;
if let Some((x, y, _)) = &at { if let Some((x, y, what)) = &at {
mac::click(*x, *y, "left", 1, 0); mac::click(*x, *y, "left", 1, 0);
std::thread::sleep(std::time::Duration::from_millis(150)); std::thread::sleep(std::time::Duration::from_millis(150));
if crate::cloudbox::replaces_field(what.split_once('(').map(|(_, w)| w).unwrap_or(what), v) {
press_keys("cmd+a", KEY_HOLD_SECS)?;
std::thread::sleep(std::time::Duration::from_millis(50));
}
} }
if !text.is_empty() { if !text.is_empty() {
mac::type_text(text); mac::type_text(text);

View File

@ -72,7 +72,9 @@ pub fn names_to_numbers(v: &mut serde_json::Value, spots: Option<&Spots>) -> Res
// ("name" in the screen tools; "text" too, the word the browser tools use.) // ("name" in the screen tools; "text" too, the word the browser tools use.)
let named = ["name", "text"].iter().find_map(|k| p[*k].as_str().map(str::trim).filter(|t| !t.is_empty()).map(String::from)); let named = ["name", "text"].iter().find_map(|k| p[*k].as_str().map(str::trim).filter(|t| !t.is_empty()).map(String::from));
if let Some(t) = named { if let Some(t) = named {
if p["ref"].is_null() && p["x"].is_null() { // A name wins over coordinates sent along with it (x: 0, y: 0 from a model filling
// every field): the click went to the screen's corner.
if p["ref"].is_null() || p["ref"].as_str().is_some_and(|r| r.trim().is_empty()) {
let s = spots.ok_or("先用 computer_use_screenshot 看一下畫面,再照清單上的名字或編號操作。")?; let s = spots.ok_or("先用 computer_use_screenshot 看一下畫面,再照清單上的名字或編號操作。")?;
p["ref"] = serde_json::Value::String(s.named(&t)?.to_string()); p["ref"] = serde_json::Value::String(s.named(&t)?.to_string());
} }
@ -348,6 +350,10 @@ mod tests {
let mut one = serde_json::json!({"name": "自動更新"}); let mut one = serde_json::json!({"name": "自動更新"});
names_to_numbers(&mut one, Some(&spots)).unwrap(); names_to_numbers(&mut one, Some(&spots)).unwrap();
assert_eq!(one["ref"], "4"); assert_eq!(one["ref"], "4");
// A name with x: 0, y: 0 beside it: the name.
let mut both = serde_json::json!({"name": "AC", "x": 0.0, "y": 0.0});
names_to_numbers(&mut both, Some(&spots)).unwrap();
assert_eq!(both["ref"], "2");
} }
#[test] #[test]