Docker calls time out; the box has a VNC key, limits and sandboxed files; X11/Wayland eyes and hands stop leaking

This commit is contained in:
王性驊 2026-11-05 15:48:22 +08:00
parent 5dfb5d1c6c
commit a8265409a1
10 changed files with 232 additions and 56 deletions

1
Cargo.lock generated
View File

@ -351,6 +351,7 @@ dependencies = [
"hayro",
"iana-time-zone",
"image",
"libc",
"memmap2",
"mermaid-rs-renderer",
"pdf-extract",

View File

@ -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"] }

View File

@ -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

View File

@ -304,7 +304,7 @@ pub fn read_person_browser(exe: &str, max_chars: usize) -> Result<Page, String>
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();

View File

@ -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());

View File

@ -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<Config> {
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<String, String> {
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<Vec<u8>, 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<u64, String> {
.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<u64, String> {
}
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<String> {
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<String, String> {
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::<String>())
.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<String>), 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<String, String>| -> Result<String, String> {
// 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<String>), 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::<Result<_, _>>()?;
if path.len() < 2 {
return Err("拖曳至少要兩個點(每個點要有數字 x 和 y,用截圖上的座標)".into());
}
@ -1374,7 +1447,8 @@ pub fn viewer_url(control: bool) -> Result<String, String> {
} 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"));

View File

@ -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<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).
@ -367,7 +377,7 @@ pub fn run(name: &str, args: &str) -> (String, bool, Option<String>) {
/// Same, remembering which bot acted (for the 「正在操作你的電腦」 bar).
pub fn run_by(name: &str, args: &str, who: &str) -> (String, bool, Option<String>) {
if name != "computer_use_screenshot" && name != "computer_use_check_permissions" {
*LAST_ACTION.lock().unwrap() = Some((std::time::Instant::now(), who.to_string()));
*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>), 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>), 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::<Result<_, _>>()?;
if path.len() < 2 {
return Err("拖曳至少要兩個點(每個點要有數字 x 和 y,用截圖上的座標)".into());
}
@ -559,7 +572,7 @@ pub fn target_app(name: &str, args: &str) -> Option<String> {
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<String> {
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<image::RgbImage, String> {
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);

View File

@ -185,7 +185,7 @@ fn jxa(script: &str, args: &Value) -> Result<String, String> {
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))

View File

@ -92,8 +92,29 @@ static WL: Mutex<Option<Wl>> = 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::<libc::timeval>() as libc::socklen_t)
};
if rc == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
fn connect() -> Option<Wl> {
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::<State>(&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<image::RgbaImage> {
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<image::RgbaImage> {
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<f64> {
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::<IdleState>(&conn) else { return };
let qh = queue.handle();
let Ok(seat) = globals.bind::<wl_seat::WlSeat, _, _>(&qh, 1..=1, ()) else { return };
let Ok(notifier) = globals.bind::<ext_idle_notifier_v1::ExtIdleNotifierV1, _, _>(&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::<IdleState>(&conn) else { return };
let qh = queue.handle();
let Ok(seat) = globals.bind::<wl_seat::WlSeat, _, _>(&qh, 1..=1, ()) else { return };
let Ok(notifier) = globals.bind::<ext_idle_notifier_v1::ExtIdleNotifierV1, _, _>(&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

View File

@ -53,16 +53,16 @@ pub fn capture() -> Result<image::RgbImage, String> {
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(())
}