From a8265409a1454c5dc271529f334246fffd3d10a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 15:48:22 +0800 Subject: [PATCH] Docker calls time out; the box has a VNC key, limits and sandboxed files; X11/Wayland eyes and hands stop leaking --- Cargo.lock | 1 + Cargo.toml | 2 + box/start.sh | 30 +++++++++++-- src/axread.rs | 2 +- src/botbrowser.rs | 9 +++- src/cloudbox.rs | 106 +++++++++++++++++++++++++++++++++++++++------- src/computer.rs | 31 ++++++++++---- src/macapps.rs | 2 +- src/wl.rs | 73 ++++++++++++++++++++++--------- src/x11.rs | 32 +++++++++++--- 10 files changed, 232 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index db37e28..aced7ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -351,6 +351,7 @@ dependencies = [ "hayro", "iana-time-zone", "image", + "libc", "memmap2", "mermaid-rs-renderer", "pdf-extract", diff --git a/Cargo.toml b/Cargo.toml index 2dc9b22..ea98312 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,8 @@ x11rb = { version = "0.13", features = ["xtest", "screensaver", "record"] } # Wayland: wlroots desktops (sway, Hyprland…) through their own protocols; GNOME and KDE through # the desktop portals (one "allow" dialog, remembered). wayland-client = "0.31" +# (wl.rs: SO_RCVTIMEO on the compositor socket, so a frozen compositor can't freeze the app) +libc = "0.2" wayland-protocols = { version = "0.32", features = ["client", "staging", "unstable"] } wayland-protocols-wlr = { version = "0.3", features = ["client"] } wayland-protocols-misc = { version = "0.3", features = ["client"] } diff --git a/box/start.sh b/box/start.sh index cfb662e..7e16c5a 100644 --- a/box/start.sh +++ b/box/start.sh @@ -8,12 +8,36 @@ cp /etc/anybot/menu /home/bot/.fluxbox/menu 2>/dev/null chown -R bot:bot /home/bot rm -f /tmp/.X1-lock /tmp/.X11-unix/X1 Xvfb :1 -screen 0 "${SCREEN_SIZE}" -nolisten tcp & +XVFB_PID=$! for i in $(seq 1 50); do xdpyinfo -display :1 >/dev/null 2>&1 && break; sleep 0.1; done su bot -c "DISPLAY=:1 xrdb -nocpp -merge /home/bot/.Xresources" su bot -c "DISPLAY=:1 fluxbox >/tmp/fluxbox.log 2>&1 &" (sleep 1.5; xsetroot -display :1 -solid '#2b2f36') & xsetroot -display :1 -solid '#2b2f36' -x11vnc -display :1 -forever -shared -nopw -quiet -rfbport 5900 -localhost >/tmp/x11vnc.log 2>&1 & +# VNC login key: the app starts the box with VNC_PASSWORD and opens the viewer with it. +# Without it (an older app) the desktop stays keyless as before. +if [ -n "${VNC_PASSWORD:-}" ]; then + mkdir -p /home/bot/.vnc + printf '%s' "$VNC_PASSWORD" | x11vnc -storepasswd /home/bot/.vnc/pass >/dev/null 2>&1 + x11vnc -display :1 -forever -shared -quiet -rfbauth /home/bot/.vnc/pass -rfbport 5900 -localhost >/tmp/x11vnc.log 2>&1 & +else + x11vnc -display :1 -forever -shared -nopw -quiet -rfbport 5900 -localhost >/tmp/x11vnc.log 2>&1 & +fi websockify --web /usr/share/novnc 6080 localhost:5900 >/tmp/novnc.log 2>&1 & -echo "anybot-box ready" > /tmp/anybot-box.ready -wait +WEBSOCKIFY_PID=$! +# Say ready only once the viewer's port listens: the app opens noVNC on this flag, and a too +# early file meant the first viewer load 404'd. +( + for i in $(seq 1 100); do + python3 -c "import socket; socket.create_connection(('127.0.0.1', 6080), 0.5)" 2>/dev/null && break + sleep 0.2 + done + echo "anybot-box ready" > /tmp/anybot-box.ready +) & +# When the desktop's heart stops, the container ends too (it used to sit there as Running +# with a dead X server, and every screen action failed forever). +wait $XVFB_PID +kill $WEBSOCKIFY_PID 2>/dev/null +pkill -f x11vnc 2>/dev/null +pkill -u bot fluxbox 2>/dev/null +exit 1 diff --git a/src/axread.rs b/src/axread.rs index e737e9c..a794bfa 100644 --- a/src/axread.rs +++ b/src/axread.rs @@ -304,7 +304,7 @@ pub fn read_person_browser(exe: &str, max_chars: usize) -> Result return Err("只支援 macOS".into()); } if !trusted(true) { - let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility").spawn(); + let _ = crate::computer::spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")); return Err("macOS 還沒讓 AnyBot 使用「輔助使用」,所以看不到你的瀏覽器。我已經打開「系統設定 → 隱私權與安全性 → 輔助使用」:把 AnyBot(或「終端機」)打開,然後再叫我試一次。只要開一次。".into()); } let ps = std::process::Command::new("ps").args(["-axo", "pid=,args="]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default(); diff --git a/src/botbrowser.rs b/src/botbrowser.rs index bddd3bf..d5a7115 100644 --- a/src/botbrowser.rs +++ b/src/botbrowser.rs @@ -278,13 +278,20 @@ pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u let _ = std::fs::remove_file(dir.join(ACTIVE_PORT_FILE)); // 「預設」 keeps its fixed port; other profiles let Chrome pick a free one and read it back. let launch_port = if profile == DEFAULT_PROFILE { default_port() } else { 0 }; + // Defense in depth: this becomes a Chrome launch argument. Every caller normalizes to a + // full URL first; a bare "-…" (a Chrome flag) must never get here from a tool argument. + let url = match first_url { + Some(u) if u.starts_with("http://") || u.starts_with("https://") => u, + Some(_) => return Err("第一個網址必須是 http(s) 網址".into()), + None => "about:blank", + }; let mut c = Command::new(bin); c.arg(format!("--user-data-dir={}", dir.display())) .arg(format!("--remote-debugging-port={launch_port}")) .arg("--remote-allow-origins=http://127.0.0.1") .arg("--no-first-run") .arg("--no-default-browser-check") - .arg(first_url.unwrap_or("about:blank")) + .arg(url) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); diff --git a/src/cloudbox.rs b/src/cloudbox.rs index 25befcb..d512d50 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -129,6 +129,10 @@ pub struct Config { /// 網路流量改走你的電腦 (new connections only). #[serde(default)] pub egress_via_mac: bool, + /// VNC password for the desktop viewer (generated on first use; the published port is + /// loopback-only, but the box's own programs can reach it, and they should need a key). + #[serde(default)] + pub vnc_password: String, } /// A scheduled update that was missed may still run for this long (the original: one hour). @@ -155,6 +159,22 @@ fn config() -> &'static Mutex { C.get_or_init(|| Mutex::new(load_config())) } +/// The box's VNC password: made once, kept across restarts (the container is started with it, +/// the viewer opens with it). x11vnc ran with no password at all before — any program inside +/// the box (a page the bot opened, for instance) could watch and drive the desktop. +pub fn vnc_password() -> String { + { + let c = config().lock().unwrap(); + if !c.vnc_password.is_empty() { + return c.vnc_password.clone(); + } + } + let pw = crate::httpd::random_hex(10); + config().lock().unwrap().vnc_password.clone_from(&pw); + save_config(&config().lock().unwrap()); + pw +} + static BUSY: AtomicUsize = AtomicUsize::new(0); /// The person took control in the viewer: the bot's screen actions wait. pub static HELD_BY_PERSON: AtomicBool = AtomicBool::new(false); @@ -378,12 +398,10 @@ fn docker_prefix() -> String { /// Run docker; Ok(stdout) or Err(stderr). fn dk(args: &[&str]) -> Result { - let out = docker().args(args).output().map_err(|e| format!("找不到 docker:{e}"))?; - if out.status.success() { - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) - } else { - Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) - } + // Never block forever on the daemon: a hung docker daemon used to pile up a new stuck + // thread every 20 s from the refresh loop. (docker's own CLI has no default timeout.) + let out = dk_timeout(args, 120_000)?; + Ok(String::from_utf8_lossy(&out).trim().to_string()) } fn dk_timeout(args: &[&str], ms: u64) -> Result, String> { @@ -493,7 +511,8 @@ fn build_image(pull: bool) -> Result<(), String> { } let d = dir.display().to_string(); args.push(&d); - let r = dk(&args); + // Builds take minutes (a cold one more than dk's short timeout allows). + let r = dk_timeout(&args, 20 * 60_000).map(|_| String::new()); let _ = std::fs::remove_dir_all(&dir); r.map(|_| log("映像檔完成。")).map_err(|e| format!("建立映像檔失敗:{}", tail(&e, 600))) } @@ -524,9 +543,10 @@ fn create_container() -> Result<(), String> { let publish = format!("127.0.0.1:{port}:{NOVNC_PORT}"); let vol = format!("{VOLUME}:{HOME}"); let env = format!("SCREEN_SIZE={SCREEN}"); + let vnc = format!("VNC_PASSWORD={}", vnc_password()); log("正在開一台新的電腦…"); // host.docker.internal → this Mac (for 網路流量改走你的電腦; Docker Desktop and OrbStack already have it). - dk(&["run", "-d", "--name", CONTAINER, "--hostname", "bot-computer", "--shm-size", "1g", "--add-host", "host.docker.internal:host-gateway", "-p", &publish, "-v", &vol, "-e", &env, "--label", "anybot.box=1", IMAGE]).map_err(|e| format!("開不了電腦:{}", tail(&e, 400)))?; + dk(&["run", "-d", "--name", CONTAINER, "--hostname", "bot-computer", "--shm-size", "1g", "--pids-limit", "4096", "--memory", "2g", "--cpus", "2", "--add-host", "host.docker.internal:host-gateway", "-p", &publish, "-v", &vol, "-e", &env, "-e", &vnc, "--label", "anybot.box=1", IMAGE]).map_err(|e| format!("開不了電腦:{}", tail(&e, 400)))?; Ok(()) } @@ -724,6 +744,15 @@ pub fn save_snapshot() -> Result { .stderr(Stdio::piped()) .spawn() .map_err(|e| e.to_string())?; + // Drain stderr: tar's file-changed warnings pile up fast, and a full 64 KB pipe stalls + // tar (and the snapshot) until the kill timer fires. + let mut se = child.stderr.take().ok_or("沒有輸出")?; + let err_t = std::thread::spawn(move || { + use std::io::Read; + let mut v = vec![]; + let _ = se.read_to_end(&mut v); + v + }); let start = Instant::now(); let ok = loop { if let Ok(Some(st)) = child.try_wait() { @@ -735,6 +764,7 @@ pub fn save_snapshot() -> Result { } std::thread::sleep(Duration::from_millis(50)); }; + let _ = err_t.join(); if !ok { let _ = std::fs::remove_file(&tmp); return Err("快照沒有存成功".into()); @@ -996,6 +1026,11 @@ pub fn set_secret(name: &str, value: &str) -> Result<(), String> { if value.is_empty() { return Err("請填入值".into()); } + // docker's --env-file keeps values on one line; a value with newlines used to be written + // with literal "\n" escapes and arrived broken (multi-line PEM keys) — say so up front. + if value.contains(['\n', '\r']) { + return Err("值裡不能有換行(多行金鑰請壓成一行,例如 -----BEGIN KEY-----…-----END KEY-----)".into()); + } if value.len() > SECRET_MAX_VALUE_BYTES { return Err(format!("值太長了(最多 {} KB)", SECRET_MAX_VALUE_BYTES / 1024)); } @@ -1022,22 +1057,49 @@ pub fn bot_dir(bot_id: u64) -> String { } /// A local shell string that runs `cmd` in the box as the bot user, in `dir`, with the secrets. -pub fn exec_wrapper(dir: &str, cmd: &str, interactive: bool) -> String { +pub fn exec_wrapper(dir: &str, cmd: &str, interactive: bool, job: &str) -> String { use crate::tools::sh_quote; - let inner = format!("mkdir -p {d} && cd {d} && {cmd}", d = sh_quote(dir)); + // The job tag rides in the command line so the app can kill the in-container process later + // (killing the local docker client alone leaves the program running in the box). + let tagged = if job.is_empty() { cmd.to_string() } else { format!("export ANYBOT_JOB={job}; {cmd}") }; + let inner = format!("mkdir -p {d} && cd {d} && {tagged}", d = sh_quote(dir)); let env = env_file_path(); let env_arg = if env.exists() { format!(" --env-file {}", sh_quote(&env.display().to_string())) } else { String::new() }; format!("{} exec{} -u bot -e DISPLAY={DISPLAY} -e HOME={HOME}{}{env_arg} {CONTAINER} bash -lc {}", docker_prefix(), if interactive { " -i" } else { "" }, egress_env(), sh_quote(&inner)) } +/// An argv that kills the box process carrying this job tag (and its children). +pub fn exec_kill_argv(job: &str) -> Vec { + let mut argv = vec!["docker".to_string()]; + let host = docker_host(); + if !host.is_empty() { + argv.extend(["-H".to_string(), host]); + } + argv.extend([ + "exec".to_string(), + CONTAINER.to_string(), + "sh".to_string(), + "-c".to_string(), + format!("for p in $(pgrep -f 'export ANYBOT_JOB={job}'); do pkill -9 -P $p; pkill -9 -P $p; kill -9 $p 2>/dev/null; done; true"), + ]); + argv +} + /// Put a file into a bot's folder on the box (only when the box is already running). pub fn copy_in(local: &str, bot_id: u64, name: &str) -> Result { if !run_state().is_running() { return Err("Bot 的電腦沒有在執行".into()); } + // The name arrives from uploads: keep only its file part, so no `../` can steer the + // root-run `docker cp` out of this bot's attachments folder. + let safe = std::path::Path::new(name) + .file_name() + .map(|n| n.to_string_lossy().chars().filter(|c| *c != '\0').collect::()) + .filter(|n| !n.is_empty() && n != "." && n != "..") + .ok_or_else(|| "檔案名稱無效".to_string())?; let dir = format!("{}/附件", bot_dir(bot_id)); dk(&["exec", "-u", "bot", CONTAINER, "mkdir", "-p", &dir])?; - let dst = format!("{dir}/{name}"); + let dst = format!("{dir}/{safe}"); dk(&["cp", local, &format!("{CONTAINER}:{dst}")])?; let _ = dk(&["exec", CONTAINER, "chown", "bot:bot", &dst]); Ok(dst) @@ -1174,11 +1236,20 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option), S Ok((format!("{}", (x * s.0).round() as i64), format!("{}", (y * s.1).round() as i64))) }; let with_mods = |mods: &[String], f: &dyn Fn() -> Result| -> Result { + // Track what went down: a failed keydown must release the keys already held, or a + // stuck modifier poisons every later click and keystroke in the box. + let mut pressed: Vec<&String> = vec![]; for m in mods { - xdo(&["keydown", m])?; + if let Err(e) = xdo(&["keydown", m]) { + for p in pressed.iter().rev() { + let _ = xdo(&["keyup", p]); + } + return Err(e); + } + pressed.push(m); } let r = f(); - for m in mods.iter().rev() { + for m in pressed.iter().rev() { let _ = xdo(&["keyup", m]); } r @@ -1237,7 +1308,9 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option), S } "computer_use_drag" => { let s = scale()?; - let path: Vec<(String, String)> = v["path"].as_array().map(|a| a.iter().filter_map(|p| pt(p, s).ok()).collect()).unwrap_or_default(); + // A point that fails to parse is an error, not a silently dropped waypoint. + let raw = v["path"].as_array().ok_or("拖曳要給 path:一個點陣,每個點有數字 x 和 y(用截圖上的座標)")?; + let path: Vec<(String, String)> = raw.iter().map(|p| pt(p, s)).collect::>()?; if path.len() < 2 { return Err("拖曳至少要兩個點(每個點要有數字 x 和 y,用截圖上的座標)".into()); } @@ -1374,7 +1447,8 @@ pub fn viewer_url(control: bool) -> Result { } else { port }; - Ok(format!("http://127.0.0.1:{local_port}/vnc.html?autoconnect=1&resize=scale&reconnect=1&show_dot=1{}", if control { "" } else { "&view_only=1" })) + // noVNC signs in with the box's VNC password (x11vnc refuses the keyless login now). + Ok(format!("http://127.0.0.1:{local_port}/vnc.html?autoconnect=1&resize=scale&reconnect=1&show_dot=1&password={}{}", vnc_password(), if control { "" } else { "&view_only=1" })) } pub fn shutdown() { @@ -1524,7 +1598,7 @@ mod tests { #[test] fn wrapper_runs_as_the_bot_in_its_folder() { - let w = exec_wrapper("/home/bot/bot-3", "ls -la", false); + let w = exec_wrapper("/home/bot/bot-3", "ls -la", false, ""); assert!(w.contains("exec -u bot")); assert!(w.contains(CONTAINER)); assert!(w.contains("/home/bot/bot-3")); diff --git a/src/computer.rs b/src/computer.rs index 9fce0fc..8dbbd35 100644 --- a/src/computer.rs +++ b/src/computer.rs @@ -18,6 +18,16 @@ pub const NAMES: [&str; 12] = ["computer_use_screenshot", "computer_use_click", pub static HELD_BY_PERSON: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); /// When the bot last moved the mouse or typed on this Mac (for the floating bar), and who. pub static LAST_ACTION: Mutex> = Mutex::new(None); + +/// Spawn a short-lived helper (`open`, a player, …) and reap it in the background: +/// spawn-without-wait left one zombie per call for the app's whole life. +pub fn spawn_reaped(c: &mut std::process::Command) -> std::io::Result<()> { + let mut child = c.spawn()?; + std::thread::spawn(move || { + let _ = child.wait(); + }); + Ok(()) +} /// Longest screenshot edge sent to the model (Gct). pub const MAX_LONG_EDGE: u32 = 1024; /// Shortest edge never goes below this (Hs). @@ -367,7 +377,7 @@ pub fn run(name: &str, args: &str) -> (String, bool, Option) { /// Same, remembering which bot acted (for the 「正在操作你的電腦」 bar). pub fn run_by(name: &str, args: &str, who: &str) -> (String, bool, Option) { if name != "computer_use_screenshot" && name != "computer_use_check_permissions" { - *LAST_ACTION.lock().unwrap() = Some((std::time::Instant::now(), who.to_string())); + *LAST_ACTION.lock().unwrap_or_else(|e| e.into_inner()) = Some((std::time::Instant::now(), who.to_string())); } let r = run_inner(name, &args_of(args)); match r { @@ -456,10 +466,10 @@ fn run_inner(name: &str, v: &Value) -> Result<(String, Option), String> return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作;可以先截圖看看。".into()); } if cfg!(target_os = "macos") && !crate::axread::trusted(true) { - let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility").spawn(); + let _ = spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")); return Err("macOS 還沒讓 AnyBot 使用「輔助使用」,所以不能動滑鼠和鍵盤。我已經打開「系統設定 → 隱私權與安全性 → 輔助使用」:把 AnyBot(或「終端機」)打開,再叫我試一次。只要開一次。".into()); } - let scale = || SCALE.lock().unwrap().ok_or_else(|| "請先用 computer_use_screenshot 看一下畫面,座標要用截圖上的位置。".to_string()); + let scale = || SCALE.lock().unwrap_or_else(|e| e.into_inner()).ok_or_else(|| "請先用 computer_use_screenshot 看一下畫面,座標要用截圖上的位置。".to_string()); let pt = |v: &Value, s: (f64, f64)| -> Result<(f64, f64), String> { let (Some(x), Some(y)) = (v["x"].as_f64(), v["y"].as_f64()) else { return Err("請給 x 和 y".into()) }; Ok(to_screen(x, y, s)) @@ -500,7 +510,10 @@ fn run_inner(name: &str, v: &Value) -> Result<(String, Option), String> } "computer_use_drag" => { let s = scale()?; - let path: Vec<(f64, f64)> = v["path"].as_array().map(|a| a.iter().filter_map(|p| pt(p, s).ok()).collect()).unwrap_or_default(); + // 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 path: Vec<(f64, f64)> = raw.iter().map(|p| pt(p, s)).collect::>()?; if path.len() < 2 { return Err("拖曳至少要兩個點(每個點要有數字 x 和 y,用截圖上的座標)".into()); } @@ -559,7 +572,7 @@ pub fn target_app(name: &str, args: &str) -> Option { if run_text_mode_name(name, &v).is_some() { return crate::desktop::last_app(); } - let s = (*SCALE.lock().unwrap())?; + let s = (*SCALE.lock().unwrap_or_else(|e| e.into_inner()))?; let point = match name { "computer_use_click" | "computer_use_mouse_move" | "computer_use_scroll" | "computer_use_mouse_button" if !v["x"].is_null() => Some(to_screen(v["x"].as_f64()?, v["y"].as_f64()?, s)), "computer_use_drag" => v["path"].as_array().and_then(|a| a.first()).and_then(|p| Some(to_screen(p["x"].as_f64()?, p["y"].as_f64()?, s))), @@ -620,7 +633,7 @@ pub fn app_at_point(x: f64, y: f64) -> Option { fn screenshot() -> Result<(String, String), String> { if !mac::screen_capture_allowed() { - let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture").spawn(); + let _ = spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture")); return Err("macOS 還沒讓 AnyBot 錄製螢幕,所以看不到畫面。我已經打開「系統設定 → 隱私權與安全性 → 螢幕與系統錄音」:把 AnyBot(或「終端機」)打開,再叫我試一次。只要開一次。".into()); } let img = capture_screen()?; @@ -630,7 +643,7 @@ fn screenshot() -> Result<(String, String), String> { let jpeg = encode_jpeg(&img).ok_or("截圖太大了,沒辦法送出")?; let (pw, ph) = mac::screen_points(); let scale = (if fw > 0 { pw / fw as f64 } else { 1.0 }, if fh > 0 { ph / fh as f64 } else { 1.0 }); - *SCALE.lock().unwrap() = Some(scale); + *SCALE.lock().unwrap_or_else(|e| e.into_inner()) = Some(scale); let b64 = base64::engine::general_purpose::STANDARD.encode(&jpeg); Ok((format!("你的螢幕截圖({fw}×{fh})。之後的座標請用這張圖上的像素位置。"), b64)) } @@ -641,7 +654,9 @@ pub fn capture_screen() -> Result { return crate::desk::capture(); #[allow(unreachable_code)] { - let file = std::env::temp_dir().join(format!("anybot-screen-{}-{}.png", std::process::id(), crate::model::now_ms())); + // 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); diff --git a/src/macapps.rs b/src/macapps.rs index 80c717f..1c4f676 100644 --- a/src/macapps.rs +++ b/src/macapps.rs @@ -185,7 +185,7 @@ fn jxa(script: &str, args: &Value) -> Result { fn permission_hint(app: &str, err: &str) -> String { let e = err.to_lowercase(); if e.contains("-1743") || e.contains("not allowed") || e.contains("不允許") || e.contains("not authorized") { - let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Automation").spawn(); + let _ = crate::computer::spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Automation")); return format!("AnyBot 還不能控制這台 Mac 的「{app}」。我已經打開「系統設定 → 隱私權與安全性 → 自動化」:在 AnyBot(或「終端機」)底下打開「{app}」,再叫我試一次。"); } format!("「{app}」回報錯誤:{}", err.lines().last().unwrap_or(err)) diff --git a/src/wl.rs b/src/wl.rs index e6da344..413a4e4 100644 --- a/src/wl.rs +++ b/src/wl.rs @@ -92,8 +92,29 @@ static WL: Mutex> = Mutex::new(None); /// The four modifiers always sit on the first keys, with their xkb modifier bits. const MODS: [(u32, u32, &str); 4] = [(0xffe1, 1, "Shift"), (0xffe3, 4, "Control"), (0xffe9, 8, "Mod1"), (0xffeb, 64, "Mod4")]; +/// Ask the kernel to give up a read on the compositor socket after `t`. A compositor that +/// stops answering used to block `blocking_dispatch` forever while the app's global Wayland +/// lock is held — every screen action (and the UI on top of it) froze. The timed-out read +/// arrives as an error, so the connection is treated as dead and rebuilt on the next call. +fn set_read_timeout(fd: std::os::fd::RawFd, t: Duration) -> std::io::Result<()> { + let tv = libc::timeval { tv_sec: i64::try_from(t.as_secs()).unwrap_or(i64::MAX), tv_usec: 0 }; + let rc = unsafe { + libc::setsockopt(fd, libc::SOL_SOCKET, libc::SO_RCVTIMEO, &tv as *const _ as *const libc::c_void, std::mem::size_of::() as libc::socklen_t) + }; + if rc == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + fn connect() -> Option { let conn = Connection::connect_to_env().ok()?; + // The compositor's socket: let the kernel give up on reads after 60 s (see + // set_read_timeout). Timed-out reads arrive as errors, so the connection is treated + // as dead and rebuilt on the next call. + use std::os::fd::AsRawFd as _; + set_read_timeout(conn.as_fd().as_raw_fd(), Duration::from_secs(60)).ok()?; let (globals, mut queue) = registry_queue_init::(&conn).ok()?; let qh = queue.handle(); let shm: wl_shm::WlShm = globals.bind(&qh, 1..=1, ()).ok()?; @@ -230,20 +251,24 @@ impl Wl { fn capture_output(&mut self, out: &wl_output::WlOutput) -> Option { self.st.frame = Frame::default(); let frame = self.copy.capture_output(0, out, &self.qh, ()); + // Every early exit passes through here, so the frame (and its buffer/pool) are always + // destroyed: failure paths used to leak compositor-side objects on every failed grab. + let r = self.grab(&frame); + frame.destroy(); + r + } + + fn grab(&mut self, frame: &frame_v1::ZwlrScreencopyFrameV1) -> Option { let deadline = Instant::now() + Duration::from_secs(5); while !(self.st.frame.failed || (self.st.frame.buffer.is_some() && (self.st.frame.buffer_done || self.copy_version < 3))) { if Instant::now() > deadline { - frame.destroy(); return None; } self.queue.blocking_dispatch(&mut self.st).ok()?; } let (format, w, h, stride) = self.st.frame.buffer?; - let WEnum::Value(fmt) = format else { - frame.destroy(); - return None; - }; - let size = (stride * h) as u64; + let WEnum::Value(fmt) = format else { return None }; + let size = (stride as u64) * (h as u64); let file = scratch_file(size).ok()?; let pool = self.shm.create_pool(file.as_fd(), size as i32, &self.qh, ()); let buffer = pool.create_buffer(0, w as i32, h as i32, stride as i32, fmt, &self.qh, ()); @@ -256,7 +281,6 @@ impl Wl { } let ok = self.st.frame.ready; let y_invert = self.st.frame.y_invert; - frame.destroy(); buffer.destroy(); pool.destroy(); if !ok { @@ -295,10 +319,12 @@ pub fn to_rgba(data: &[u8], fmt: wl_shm::Format, w: u32, h: u32, stride: u32, y_ wl_shm::Format::Xbgr8888 | wl_shm::Format::Abgr8888 => false, _ => return None, }; - let mut out = Vec::with_capacity((w * h * 4) as usize); + // Byte maths in usize: extreme resolutions overflow a u32 product (debug builds panic). + let mut out = Vec::with_capacity((w as usize) * (h as usize) * 4); for row in 0..h { let r = if y_invert { h - 1 - row } else { row }; - let line = data.get((r * stride) as usize..(r * stride + w * 4) as usize)?; + let (a, b) = ((r as u64 * stride as u64) as usize, ((r as u64 * stride as u64) as usize) + (w as usize) * 4); + let line = data.get(a..b)?; for px in line.chunks_exact(4) { if bgr { out.extend_from_slice(&[px[2], px[1], px[0], 255]); @@ -425,16 +451,25 @@ pub fn idle_secs() -> Option { static STARTED: std::sync::Once = std::sync::Once::new(); STARTED.call_once(|| { std::thread::spawn(|| { - let Ok(conn) = Connection::connect_to_env() else { return }; - let Ok((globals, mut queue)) = registry_queue_init::(&conn) else { return }; - let qh = queue.handle(); - let Ok(seat) = globals.bind::(&qh, 1..=1, ()) else { return }; - let Ok(notifier) = globals.bind::(&qh, 1..=1, ()) else { return }; - notifier.get_idle_notification(1000, &seat, &qh, ()); - *IDLE.lock().unwrap_or_else(|e| e.into_inner()) = Some(None); - let mut st = IdleState; - while queue.blocking_dispatch(&mut st).is_ok() {} - *IDLE.lock().unwrap_or_else(|e| e.into_inner()) = None; + // Keep listening for the app's whole life: when the connection drops (compositor + // restart, read timeout) reconnect and listen again — the old loop quit forever + // after one break and idle_secs() went blind. + loop { + let Ok(conn) = Connection::connect_to_env() else { return }; + let Ok((globals, mut queue)) = registry_queue_init::(&conn) else { return }; + let qh = queue.handle(); + let Ok(seat) = globals.bind::(&qh, 1..=1, ()) else { return }; + let Ok(notifier) = globals.bind::(&qh, 1..=1, ()) else { return }; + // Keep the notification object alive for as long as the loop runs: wayland-client + // destroys an object when its last handle drops, and dropping this return value + // here meant Idled events never arrived (idle_secs() was forever 0). + let _notification = notifier.get_idle_notification(1000, &seat, &qh, ()); + *IDLE.lock().unwrap_or_else(|e| e.into_inner()) = Some(None); + let mut st = IdleState; + while queue.blocking_dispatch(&mut st).is_ok() {} + *IDLE.lock().unwrap_or_else(|e| e.into_inner()) = None; + std::thread::sleep(Duration::from_secs(2)); + } }); }); None diff --git a/src/x11.rs b/src/x11.rs index d75ad6b..4cfd078 100644 --- a/src/x11.rs +++ b/src/x11.rs @@ -53,16 +53,16 @@ pub fn capture() -> Result { return None; } let lsb = setup.image_byte_order == xproto::ImageOrder::LSB_FIRST; - let (w, h) = (x.width as u32, x.height as u32); - let mut rgb = Vec::with_capacity((w * h * 3) as usize); - for px in reply.data.chunks_exact(4).take((w * h) as usize) { + let (w, h) = (x.width as usize, x.height as usize); + let mut rgb = Vec::with_capacity(w * h * 3); + for px in reply.data.chunks_exact(4).take(w * h) { if lsb { rgb.extend_from_slice(&[px[2], px[1], px[0]]); } else { rgb.extend_from_slice(&[px[1], px[2], px[3]]); } } - image::RgbImage::from_raw(w, h, rgb) + image::RgbImage::from_raw(w as u32, h as u32, rgb) }) .ok_or_else(|| "讀不到螢幕畫面(需要 X11 桌面;Wayland 請在登入畫面選「Xorg」工作階段)".to_string()) } @@ -302,7 +302,11 @@ pub fn type_text(text: &str) { for c in text.chars() { let sym = keysym_for_char(c); let syms = vec![sym; per]; - x.conn.change_keyboard_mapping(1, spare, per as u8, &syms).ok()?; + // On any failure stop the loop and fall through to restoring the mapping: an + // early `?` here left the typed character glued to a real key forever. + if x.conn.change_keyboard_mapping(1, spare, per as u8, &syms).is_err() { + break; + } flush(x); std::thread::sleep(Duration::from_millis(12)); fake(x, xproto::KEY_PRESS_EVENT, spare, 0, 0); @@ -428,11 +432,13 @@ pub fn record(mut each: impl FnMut(Input), keep_going: impl Fn() -> bool + Send _ => 0, } }; + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let stopper = std::thread::spawn({ let ctx = ctx; + let stop = stop.clone(); move || { // Disabling the context ends the data stream below. - while keep_going() { + while keep_going() && !stop.load(std::sync::atomic::Ordering::Relaxed) { std::thread::sleep(Duration::from_millis(150)); } let _ = ctrl.record_disable_context(ctx); @@ -442,7 +448,16 @@ pub fn record(mut each: impl FnMut(Input), keep_going: impl Fn() -> bool + Send let _ = ctrl.flush(); } }); - let stream = data.record_enable_context(ctx).map_err(|e| e.to_string())?; + let stream = match data.record_enable_context(ctx) { + Ok(s) => s, + // A failed start must stop the watchdog thread too — it used to spin forever + // (holding an X connection) on this early return. + Err(e) => { + stop.store(true, std::sync::atomic::Ordering::Relaxed); + let _ = stopper.join(); + return Err(e.to_string()); + } + }; for reply in stream { let Ok(reply) = reply else { break }; if reply.category != 0 { @@ -482,6 +497,9 @@ pub fn record(mut each: impl FnMut(Input), keep_going: impl Fn() -> bool + Send } } } + // The stream broke (the X server died, the context was dropped): tell the watchdog to + // stop first — waiting on a thread that keeps polling forever is what froze the old code. + stop.store(true, std::sync::atomic::Ordering::Relaxed); let _ = stopper.join(); Ok(()) }