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:
parent
5dfb5d1c6c
commit
a8265409a1
|
|
@ -351,6 +351,7 @@ dependencies = [
|
||||||
"hayro",
|
"hayro",
|
||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
"image",
|
"image",
|
||||||
|
"libc",
|
||||||
"memmap2",
|
"memmap2",
|
||||||
"mermaid-rs-renderer",
|
"mermaid-rs-renderer",
|
||||||
"pdf-extract",
|
"pdf-extract",
|
||||||
|
|
|
||||||
|
|
@ -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
|
# Wayland: wlroots desktops (sway, Hyprland…) through their own protocols; GNOME and KDE through
|
||||||
# the desktop portals (one "allow" dialog, remembered).
|
# the desktop portals (one "allow" dialog, remembered).
|
||||||
wayland-client = "0.31"
|
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 = { version = "0.32", features = ["client", "staging", "unstable"] }
|
||||||
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
|
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
|
||||||
wayland-protocols-misc = { version = "0.3", features = ["client"] }
|
wayland-protocols-misc = { version = "0.3", features = ["client"] }
|
||||||
|
|
|
||||||
30
box/start.sh
30
box/start.sh
|
|
@ -8,12 +8,36 @@ cp /etc/anybot/menu /home/bot/.fluxbox/menu 2>/dev/null
|
||||||
chown -R bot:bot /home/bot
|
chown -R bot:bot /home/bot
|
||||||
rm -f /tmp/.X1-lock /tmp/.X11-unix/X1
|
rm -f /tmp/.X1-lock /tmp/.X11-unix/X1
|
||||||
Xvfb :1 -screen 0 "${SCREEN_SIZE}" -nolisten tcp &
|
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
|
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 xrdb -nocpp -merge /home/bot/.Xresources"
|
||||||
su bot -c "DISPLAY=:1 fluxbox >/tmp/fluxbox.log 2>&1 &"
|
su bot -c "DISPLAY=:1 fluxbox >/tmp/fluxbox.log 2>&1 &"
|
||||||
(sleep 1.5; xsetroot -display :1 -solid '#2b2f36') &
|
(sleep 1.5; xsetroot -display :1 -solid '#2b2f36') &
|
||||||
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 &
|
websockify --web /usr/share/novnc 6080 localhost:5900 >/tmp/novnc.log 2>&1 &
|
||||||
echo "anybot-box ready" > /tmp/anybot-box.ready
|
WEBSOCKIFY_PID=$!
|
||||||
wait
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -304,7 +304,7 @@ pub fn read_person_browser(exe: &str, max_chars: usize) -> Result<Page, String>
|
||||||
return Err("只支援 macOS".into());
|
return Err("只支援 macOS".into());
|
||||||
}
|
}
|
||||||
if !trusted(true) {
|
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());
|
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();
|
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();
|
||||||
|
|
|
||||||
|
|
@ -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));
|
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.
|
// 「預設」 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 };
|
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);
|
let mut c = Command::new(bin);
|
||||||
c.arg(format!("--user-data-dir={}", dir.display()))
|
c.arg(format!("--user-data-dir={}", dir.display()))
|
||||||
.arg(format!("--remote-debugging-port={launch_port}"))
|
.arg(format!("--remote-debugging-port={launch_port}"))
|
||||||
.arg("--remote-allow-origins=http://127.0.0.1")
|
.arg("--remote-allow-origins=http://127.0.0.1")
|
||||||
.arg("--no-first-run")
|
.arg("--no-first-run")
|
||||||
.arg("--no-default-browser-check")
|
.arg("--no-default-browser-check")
|
||||||
.arg(first_url.unwrap_or("about:blank"))
|
.arg(url)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null());
|
.stderr(Stdio::null());
|
||||||
|
|
|
||||||
106
src/cloudbox.rs
106
src/cloudbox.rs
|
|
@ -129,6 +129,10 @@ pub struct Config {
|
||||||
/// 網路流量改走你的電腦 (new connections only).
|
/// 網路流量改走你的電腦 (new connections only).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub egress_via_mac: bool,
|
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).
|
/// 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()))
|
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);
|
static BUSY: AtomicUsize = AtomicUsize::new(0);
|
||||||
/// The person took control in the viewer: the bot's screen actions wait.
|
/// The person took control in the viewer: the bot's screen actions wait.
|
||||||
pub static HELD_BY_PERSON: AtomicBool = AtomicBool::new(false);
|
pub static HELD_BY_PERSON: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
@ -378,12 +398,10 @@ fn docker_prefix() -> String {
|
||||||
|
|
||||||
/// 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> {
|
||||||
let out = docker().args(args).output().map_err(|e| format!("找不到 docker:{e}"))?;
|
// Never block forever on the daemon: a hung docker daemon used to pile up a new stuck
|
||||||
if out.status.success() {
|
// thread every 20 s from the refresh loop. (docker's own CLI has no default timeout.)
|
||||||
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
let out = dk_timeout(args, 120_000)?;
|
||||||
} else {
|
Ok(String::from_utf8_lossy(&out).trim().to_string())
|
||||||
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dk_timeout(args: &[&str], ms: u64) -> Result<Vec<u8>, 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();
|
let d = dir.display().to_string();
|
||||||
args.push(&d);
|
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);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
r.map(|_| log("映像檔完成。")).map_err(|e| format!("建立映像檔失敗:{}", tail(&e, 600)))
|
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 publish = format!("127.0.0.1:{port}:{NOVNC_PORT}");
|
||||||
let vol = format!("{VOLUME}:{HOME}");
|
let vol = format!("{VOLUME}:{HOME}");
|
||||||
let env = format!("SCREEN_SIZE={SCREEN}");
|
let env = format!("SCREEN_SIZE={SCREEN}");
|
||||||
|
let vnc = format!("VNC_PASSWORD={}", vnc_password());
|
||||||
log("正在開一台新的電腦…");
|
log("正在開一台新的電腦…");
|
||||||
// host.docker.internal → this Mac (for 網路流量改走你的電腦; Docker Desktop and OrbStack already have it).
|
// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -724,6 +744,15 @@ pub fn save_snapshot() -> Result<u64, String> {
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| e.to_string())?;
|
.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 start = Instant::now();
|
||||||
let ok = loop {
|
let ok = loop {
|
||||||
if let Ok(Some(st)) = child.try_wait() {
|
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));
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
};
|
};
|
||||||
|
let _ = err_t.join();
|
||||||
if !ok {
|
if !ok {
|
||||||
let _ = std::fs::remove_file(&tmp);
|
let _ = std::fs::remove_file(&tmp);
|
||||||
return Err("快照沒有存成功".into());
|
return Err("快照沒有存成功".into());
|
||||||
|
|
@ -996,6 +1026,11 @@ pub fn set_secret(name: &str, value: &str) -> Result<(), String> {
|
||||||
if value.is_empty() {
|
if value.is_empty() {
|
||||||
return Err("請填入值".into());
|
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 {
|
if value.len() > SECRET_MAX_VALUE_BYTES {
|
||||||
return Err(format!("值太長了(最多 {} KB)", SECRET_MAX_VALUE_BYTES / 1024));
|
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.
|
/// 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;
|
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 = env_file_path();
|
||||||
let env_arg = if env.exists() { format!(" --env-file {}", sh_quote(&env.display().to_string())) } else { String::new() };
|
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))
|
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).
|
/// 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> {
|
pub fn copy_in(local: &str, bot_id: u64, name: &str) -> Result<String, String> {
|
||||||
if !run_state().is_running() {
|
if !run_state().is_running() {
|
||||||
return Err("Bot 的電腦沒有在執行".into());
|
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));
|
let dir = format!("{}/附件", bot_dir(bot_id));
|
||||||
dk(&["exec", "-u", "bot", CONTAINER, "mkdir", "-p", &dir])?;
|
dk(&["exec", "-u", "bot", CONTAINER, "mkdir", "-p", &dir])?;
|
||||||
let dst = format!("{dir}/{name}");
|
let dst = format!("{dir}/{safe}");
|
||||||
dk(&["cp", local, &format!("{CONTAINER}:{dst}")])?;
|
dk(&["cp", local, &format!("{CONTAINER}:{dst}")])?;
|
||||||
let _ = dk(&["exec", CONTAINER, "chown", "bot:bot", &dst]);
|
let _ = dk(&["exec", CONTAINER, "chown", "bot:bot", &dst]);
|
||||||
Ok(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)))
|
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> {
|
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 {
|
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();
|
let r = f();
|
||||||
for m in mods.iter().rev() {
|
for m in pressed.iter().rev() {
|
||||||
let _ = xdo(&["keyup", m]);
|
let _ = xdo(&["keyup", m]);
|
||||||
}
|
}
|
||||||
r
|
r
|
||||||
|
|
@ -1237,7 +1308,9 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option<String>), S
|
||||||
}
|
}
|
||||||
"computer_use_drag" => {
|
"computer_use_drag" => {
|
||||||
let s = scale()?;
|
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 {
|
if path.len() < 2 {
|
||||||
return Err("拖曳至少要兩個點(每個點要有數字 x 和 y,用截圖上的座標)".into());
|
return Err("拖曳至少要兩個點(每個點要有數字 x 和 y,用截圖上的座標)".into());
|
||||||
}
|
}
|
||||||
|
|
@ -1374,7 +1447,8 @@ pub fn viewer_url(control: bool) -> Result<String, String> {
|
||||||
} else {
|
} else {
|
||||||
port
|
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() {
|
pub fn shutdown() {
|
||||||
|
|
@ -1524,7 +1598,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wrapper_runs_as_the_bot_in_its_folder() {
|
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("exec -u bot"));
|
||||||
assert!(w.contains(CONTAINER));
|
assert!(w.contains(CONTAINER));
|
||||||
assert!(w.contains("/home/bot/bot-3"));
|
assert!(w.contains("/home/bot/bot-3"));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
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.
|
/// 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);
|
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).
|
/// Longest screenshot edge sent to the model (Gct).
|
||||||
pub const MAX_LONG_EDGE: u32 = 1024;
|
pub const MAX_LONG_EDGE: u32 = 1024;
|
||||||
/// Shortest edge never goes below this (Hs).
|
/// 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).
|
/// Same, remembering which bot acted (for the 「正在操作你的電腦」 bar).
|
||||||
pub fn run_by(name: &str, args: &str, who: &str) -> (String, bool, Option<String>) {
|
pub fn run_by(name: &str, args: &str, who: &str) -> (String, bool, Option<String>) {
|
||||||
if name != "computer_use_screenshot" && name != "computer_use_check_permissions" {
|
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));
|
let r = run_inner(name, &args_of(args));
|
||||||
match r {
|
match r {
|
||||||
|
|
@ -456,10 +466,10 @@ fn run_inner(name: &str, v: &Value) -> Result<(String, Option<String>), String>
|
||||||
return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作;可以先截圖看看。".into());
|
return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作;可以先截圖看看。".into());
|
||||||
}
|
}
|
||||||
if cfg!(target_os = "macos") && !crate::axread::trusted(true) {
|
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());
|
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 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()) };
|
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))
|
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" => {
|
"computer_use_drag" => {
|
||||||
let s = scale()?;
|
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 {
|
if path.len() < 2 {
|
||||||
return Err("拖曳至少要兩個點(每個點要有數字 x 和 y,用截圖上的座標)".into());
|
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() {
|
if run_text_mode_name(name, &v).is_some() {
|
||||||
return crate::desktop::last_app();
|
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 {
|
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_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))),
|
"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> {
|
fn screenshot() -> Result<(String, String), String> {
|
||||||
if !mac::screen_capture_allowed() {
|
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());
|
return Err("macOS 還沒讓 AnyBot 錄製螢幕,所以看不到畫面。我已經打開「系統設定 → 隱私權與安全性 → 螢幕與系統錄音」:把 AnyBot(或「終端機」)打開,再叫我試一次。只要開一次。".into());
|
||||||
}
|
}
|
||||||
let img = capture_screen()?;
|
let img = capture_screen()?;
|
||||||
|
|
@ -630,7 +643,7 @@ fn screenshot() -> Result<(String, String), String> {
|
||||||
let jpeg = encode_jpeg(&img).ok_or("截圖太大了,沒辦法送出")?;
|
let jpeg = encode_jpeg(&img).ok_or("截圖太大了,沒辦法送出")?;
|
||||||
let (pw, ph) = mac::screen_points();
|
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 });
|
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);
|
let b64 = base64::engine::general_purpose::STANDARD.encode(&jpeg);
|
||||||
Ok((format!("你的螢幕截圖({fw}×{fh})。之後的座標請用這張圖上的像素位置。"), b64))
|
Ok((format!("你的螢幕截圖({fw}×{fh})。之後的座標請用這張圖上的像素位置。"), b64))
|
||||||
}
|
}
|
||||||
|
|
@ -641,7 +654,9 @@ pub fn capture_screen() -> Result<image::RgbImage, String> {
|
||||||
return crate::desk::capture();
|
return crate::desk::capture();
|
||||||
#[allow(unreachable_code)]
|
#[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 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 bytes = std::fs::read(&file);
|
||||||
let _ = std::fs::remove_file(&file);
|
let _ = std::fs::remove_file(&file);
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,7 @@ fn jxa(script: &str, args: &Value) -> Result<String, String> {
|
||||||
fn permission_hint(app: &str, err: &str) -> String {
|
fn permission_hint(app: &str, err: &str) -> String {
|
||||||
let e = err.to_lowercase();
|
let e = err.to_lowercase();
|
||||||
if e.contains("-1743") || e.contains("not allowed") || e.contains("不允許") || e.contains("not authorized") {
|
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}」,再叫我試一次。");
|
return format!("AnyBot 還不能控制這台 Mac 的「{app}」。我已經打開「系統設定 → 隱私權與安全性 → 自動化」:在 AnyBot(或「終端機」)底下打開「{app}」,再叫我試一次。");
|
||||||
}
|
}
|
||||||
format!("「{app}」回報錯誤:{}", err.lines().last().unwrap_or(err))
|
format!("「{app}」回報錯誤:{}", err.lines().last().unwrap_or(err))
|
||||||
|
|
|
||||||
55
src/wl.rs
55
src/wl.rs
|
|
@ -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.
|
/// 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")];
|
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> {
|
fn connect() -> Option<Wl> {
|
||||||
let conn = Connection::connect_to_env().ok()?;
|
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 (globals, mut queue) = registry_queue_init::<State>(&conn).ok()?;
|
||||||
let qh = queue.handle();
|
let qh = queue.handle();
|
||||||
let shm: wl_shm::WlShm = globals.bind(&qh, 1..=1, ()).ok()?;
|
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> {
|
fn capture_output(&mut self, out: &wl_output::WlOutput) -> Option<image::RgbaImage> {
|
||||||
self.st.frame = Frame::default();
|
self.st.frame = Frame::default();
|
||||||
let frame = self.copy.capture_output(0, out, &self.qh, ());
|
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);
|
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))) {
|
while !(self.st.frame.failed || (self.st.frame.buffer.is_some() && (self.st.frame.buffer_done || self.copy_version < 3))) {
|
||||||
if Instant::now() > deadline {
|
if Instant::now() > deadline {
|
||||||
frame.destroy();
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
self.queue.blocking_dispatch(&mut self.st).ok()?;
|
self.queue.blocking_dispatch(&mut self.st).ok()?;
|
||||||
}
|
}
|
||||||
let (format, w, h, stride) = self.st.frame.buffer?;
|
let (format, w, h, stride) = self.st.frame.buffer?;
|
||||||
let WEnum::Value(fmt) = format else {
|
let WEnum::Value(fmt) = format else { return None };
|
||||||
frame.destroy();
|
let size = (stride as u64) * (h as u64);
|
||||||
return None;
|
|
||||||
};
|
|
||||||
let size = (stride * h) as u64;
|
|
||||||
let file = scratch_file(size).ok()?;
|
let file = scratch_file(size).ok()?;
|
||||||
let pool = self.shm.create_pool(file.as_fd(), size as i32, &self.qh, ());
|
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, ());
|
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 ok = self.st.frame.ready;
|
||||||
let y_invert = self.st.frame.y_invert;
|
let y_invert = self.st.frame.y_invert;
|
||||||
frame.destroy();
|
|
||||||
buffer.destroy();
|
buffer.destroy();
|
||||||
pool.destroy();
|
pool.destroy();
|
||||||
if !ok {
|
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,
|
wl_shm::Format::Xbgr8888 | wl_shm::Format::Abgr8888 => false,
|
||||||
_ => return None,
|
_ => 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 {
|
for row in 0..h {
|
||||||
let r = if y_invert { h - 1 - row } else { row };
|
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) {
|
for px in line.chunks_exact(4) {
|
||||||
if bgr {
|
if bgr {
|
||||||
out.extend_from_slice(&[px[2], px[1], px[0], 255]);
|
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();
|
static STARTED: std::sync::Once = std::sync::Once::new();
|
||||||
STARTED.call_once(|| {
|
STARTED.call_once(|| {
|
||||||
std::thread::spawn(|| {
|
std::thread::spawn(|| {
|
||||||
|
// 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(conn) = Connection::connect_to_env() else { return };
|
||||||
let Ok((globals, mut queue)) = registry_queue_init::<IdleState>(&conn) else { return };
|
let Ok((globals, mut queue)) = registry_queue_init::<IdleState>(&conn) else { return };
|
||||||
let qh = queue.handle();
|
let qh = queue.handle();
|
||||||
let Ok(seat) = globals.bind::<wl_seat::WlSeat, _, _>(&qh, 1..=1, ()) else { return };
|
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 };
|
let Ok(notifier) = globals.bind::<ext_idle_notifier_v1::ExtIdleNotifierV1, _, _>(&qh, 1..=1, ()) else { return };
|
||||||
notifier.get_idle_notification(1000, &seat, &qh, ());
|
// 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);
|
*IDLE.lock().unwrap_or_else(|e| e.into_inner()) = Some(None);
|
||||||
let mut st = IdleState;
|
let mut st = IdleState;
|
||||||
while queue.blocking_dispatch(&mut st).is_ok() {}
|
while queue.blocking_dispatch(&mut st).is_ok() {}
|
||||||
*IDLE.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
*IDLE.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
|
std::thread::sleep(Duration::from_secs(2));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
None
|
None
|
||||||
|
|
|
||||||
32
src/x11.rs
32
src/x11.rs
|
|
@ -53,16 +53,16 @@ pub fn capture() -> Result<image::RgbImage, String> {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let lsb = setup.image_byte_order == xproto::ImageOrder::LSB_FIRST;
|
let lsb = setup.image_byte_order == xproto::ImageOrder::LSB_FIRST;
|
||||||
let (w, h) = (x.width as u32, x.height as u32);
|
let (w, h) = (x.width as usize, x.height as usize);
|
||||||
let mut rgb = Vec::with_capacity((w * h * 3) as usize);
|
let mut rgb = Vec::with_capacity(w * h * 3);
|
||||||
for px in reply.data.chunks_exact(4).take((w * h) as usize) {
|
for px in reply.data.chunks_exact(4).take(w * h) {
|
||||||
if lsb {
|
if lsb {
|
||||||
rgb.extend_from_slice(&[px[2], px[1], px[0]]);
|
rgb.extend_from_slice(&[px[2], px[1], px[0]]);
|
||||||
} else {
|
} else {
|
||||||
rgb.extend_from_slice(&[px[1], px[2], px[3]]);
|
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())
|
.ok_or_else(|| "讀不到螢幕畫面(需要 X11 桌面;Wayland 請在登入畫面選「Xorg」工作階段)".to_string())
|
||||||
}
|
}
|
||||||
|
|
@ -302,7 +302,11 @@ pub fn type_text(text: &str) {
|
||||||
for c in text.chars() {
|
for c in text.chars() {
|
||||||
let sym = keysym_for_char(c);
|
let sym = keysym_for_char(c);
|
||||||
let syms = vec![sym; per];
|
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);
|
flush(x);
|
||||||
std::thread::sleep(Duration::from_millis(12));
|
std::thread::sleep(Duration::from_millis(12));
|
||||||
fake(x, xproto::KEY_PRESS_EVENT, spare, 0, 0);
|
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,
|
_ => 0,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
let stopper = std::thread::spawn({
|
let stopper = std::thread::spawn({
|
||||||
let ctx = ctx;
|
let ctx = ctx;
|
||||||
|
let stop = stop.clone();
|
||||||
move || {
|
move || {
|
||||||
// Disabling the context ends the data stream below.
|
// 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));
|
std::thread::sleep(Duration::from_millis(150));
|
||||||
}
|
}
|
||||||
let _ = ctrl.record_disable_context(ctx);
|
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 _ = 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 {
|
for reply in stream {
|
||||||
let Ok(reply) = reply else { break };
|
let Ok(reply) = reply else { break };
|
||||||
if reply.category != 0 {
|
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();
|
let _ = stopper.join();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue