Demo recording keeps its last events off the UI thread; dialogs, screensaver probe and installer traps fixed

This commit is contained in:
王性驊 2026-11-05 15:48:22 +08:00
parent a8265409a1
commit 21b917b0eb
6 changed files with 140 additions and 48 deletions

View File

@ -11,7 +11,10 @@
export PATH := $(HOME)/.cargo/bin:$(PATH)
CARGO ?= cargo
UNAME := $(shell uname -s)
VERSION ?= $(shell git describe --tags --always 2>/dev/null | sed 's/^v//' || echo dev)
# `git describe` prints nothing outside a repo; sed passes the empty string through with
# status 0, so "|| echo dev" never fired and VERSION came out blank ("AnyBot ", dist files
# named ".tar.gz"). grep . fails on empty input, which makes the fallback work.
VERSION ?= $(shell git describe --tags --always 2>/dev/null | sed 's/^v//' | grep . || echo dev)
TARGET := $(or $(CARGO_TARGET_DIR),target)
APPDIR ?= $(HOME)/Applications
BIN := $(TARGET)/release/anybot
@ -45,7 +48,7 @@ build: app relay
check-rust:
@command -v $(CARGO) >/dev/null 2>&1 || { echo; echo " ✗ 找不到 Rust(cargo)。先執行:make deps"; echo; exit 1; }
@v=$$(rustc --version | sed -n 's/^rustc 1\.\([0-9]*\).*/\1/p'); [ "$${v:-0}" -ge 95 ] || { echo; echo " ✗ Rust 太舊($$(rustc --version)),要 1.95 以上。執行:rustup update stable(或 make deps)"; echo; exit 1; }
@set -- $$(rustc --version | sed -n 's/^rustc \([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1 \2/p'); { [ "$${1:-0}" -gt 1 ] || { [ "$${1:-0}" -eq 1 ] && [ "$${2:-0}" -ge 95 ]; }; } || { echo; echo " ✗ Rust 太舊($$(rustc --version)),要 1.95 以上。執行:rustup update stable(或 make deps)"; echo; exit 1; }
app: check-rust
$(CARGO) build --release --bin anybot

View File

@ -23,7 +23,16 @@ MODE=${1:-auto}
[ $# -gt 0 ] && shift
case $MODE in
app|hands|headless|server|deps|auto) ;;
-h|--help|help) sed -n '2,14p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//'; exit 0 ;;
-h|--help|help)
# 用 curl | sh 執行時 $0 是「sh」,讀不到腳本原文:改印內嵌的說明。
usage=$(sed -n '2,14p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//' || true)
[ -n "$usage" ] || usage='AnyBot 一鍵安裝:
sh install.sh 桌面 App(Mac、有螢幕的 Linux)
sh install.sh hands [網址 配對碼] 沒有螢幕的 Linux,接成大腦的手腳
sh install.sh server 伺服器:帳號伺服器 + 大腦 + HTTPS(Docker)
sh install.sh deps 只裝建置需要的東西(之後自己 make)
環境變數:ANYBOT_DIR(原始碼位置,預設 ~/AnyBot)、ANYBOT_BRANCH(預設 main)。'
printf '%s\n' "$usage"; exit 0 ;;
*) echo "不認得「${MODE}」。可以用:app、hands、server、deps"; exit 1 ;;
esac
[ "$MODE" = headless ] && MODE=hands
@ -43,6 +52,9 @@ have() { command -v "$1" >/dev/null 2>&1; }
TTY=/dev/tty
[ -r "$TTY" ] && [ -w "$TTY" ] && (exec <"$TTY") 2>/dev/null || TTY=
# PATH 提示要看的是使用者登入後的 PATH,不是腳本自己改過的(下面會加 ~/.local/bin)。
START_PATH=$PATH
# ---------- 這台是什麼 ----------
OS=$(uname -s); ARCH=$(uname -m)
case $OS in
@ -79,6 +91,8 @@ printf '\n %s◆ AnyBot 安裝%s %s%s · %s %s%s\n' "$C$B" "$N" "$D" "$TITLE"
# ---------- 系統套件 ----------
# apt:只裝這個發行版有的名字(例如 Ubuntu 24.04 的 libasound2 叫 libasound2t64)。
# 最後加 true:清單最後一個套件不存在時,最後一条 apt-cache 判斷會失敗,
# 在 set -e 下整個腳本會無訊息退出(呼叫端是 pkgs=$(apt_pick …))。
apt_pick() {
for p in "$@"; do
case $p in
@ -89,6 +103,16 @@ apt_pick() {
*) apt-cache show "$p" >/dev/null 2>&1 && echo "$p" ;;
esac
done
true
}
# zypper:同 apt_pick,裝之前先過濾掉這台找不到的名字(SUSE 各版本套件名差異大,
# 一個名字不存在會讓整組安裝失敗)。
zypper_pick() {
for p in "$@"; do
zypper --non-interactive info "$p" >/dev/null 2>&1 && echo "$p"
done
true
}
linux_packages() {
@ -137,8 +161,14 @@ linux_packages() {
;;
zypper)
base="gcc gcc-c++ make pkg-config git curl alsa-devel"
extra=""
# app/hands 也要有這台该有的套件(之前只有 base,桌面模式缺 xkbcommon/GL/通知…,
# 編譯或執行時才炸)。名字逐個過濾,找不到的跳過。
[ "$1" = app ] && extra="libxkbcommon-x11-0 Mesa libGL1 xdg-utils libnotify-tools libnotify4 libsecret-1-0 espeak zenity xclip wl-clipboard ffmpeg fonts-noto"
[ "$1" = hands ] && extra="xorg-x11-server-Xvfb openbox at-spi2-core at-spi2-atk-common dbus-1-x11 xauth fonts-noto"
[ "$1" = server ] && base="curl git"
$SUDO zypper -n install $base || die "安裝系統套件失敗。" "sudo zypper install $base"
pkgs=$(zypper_pick $base $extra)
$SUDO zypper -n install $pkgs || die "安裝系統套件失敗。" "sudo zypper install $pkgs"
;;
*)
die "認不出這台的套件管理程式(apt、dnf、pacman、zypper 都沒有)。" \
@ -157,8 +187,11 @@ mac_tools() {
# ---------- Rust ----------
rust_ok() {
have cargo && have rustc || return 1
minor=$(rustc --version 2>/dev/null | sed -n 's/^rustc 1\.\([0-9]*\).*/\1/p')
[ -n "$minor" ] && [ "$minor" -ge $RUST_MIN ]
# 抓「主版號 次版號」:舊寫法死鎖在 1.x,rustc 2.x 會被当成「太舊」。
set -- $(rustc --version 2>/dev/null | sed -n 's/^rustc \([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1 \2/p')
[ $# -ge 2 ] || return 1
[ "$1" -gt 1 ] && return 0
[ "$1" -eq 1 ] && [ "$2" -ge "$RUST_MIN" ]
}
ensure_rust() {
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
@ -166,6 +199,8 @@ ensure_rust() {
if have rustup; then
info "Rust 太舊,更新中(rustup update stable)…"
rustup update stable >/dev/null 2>&1 && rustup default stable >/dev/null 2>&1 || true
# rustup 裝在 ~/.cargo/bin;這個終端機可能還沒有它(--no-modify-path 裝的)。
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
else
if have rustc; then warn "系統的 Rust $(rustc --version | cut -d' ' -f2) 太舊(要 1.$RUST_MIN 以上),另外用 rustup 裝新版"; fi
info "安裝 Rust(rustup,裝在 ~/.cargo,不用 root)…"
@ -326,6 +361,6 @@ case $MODE in
if ! have docker; then info "Bot 自己的電腦(一個隔離的桌面)需要 Docker,要用時 AnyBot 會告訴你怎麼裝。"; fi
info "要更新:再執行一次同一行指令。"
fi
case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) [ $PLAT = linux ] && info "(終端機找不到 anybot 的話,重新登入,或把 ~/.local/bin 加進 PATH)" ;; esac
case ":$START_PATH:" in *":$HOME/.local/bin:"*) ;; *) [ $PLAT = linux ] && info "(終端機找不到 anybot 的話,重新登入,或把 ~/.local/bin 加進 PATH)" ;; esac
;;
esac

View File

@ -308,7 +308,11 @@ pub fn screensaver_on() -> bool {
if !wayland() {
return x11::screensaver_on();
}
// GNOME speaks org.gnome.ScreenSaver.GetActive at /org/gnome/ScreenSaver; the plain
// freedesktop one (KDE & co.) is org.freedesktop.ScreenSaver.IsActive at /ScreenSaver —
// the old fallback asked for a path and method that never exist, so on KDE it always
// answered "screen is free".
dbus_call::<bool>("org.gnome.ScreenSaver", "/org/gnome/ScreenSaver", "org.gnome.ScreenSaver", "GetActive")
.or_else(|| dbus_call::<bool>("org.freedesktop.ScreenSaver", "/org/freedesktop/ScreenSaver", "org.freedesktop.ScreenSaver", "GetActive"))
.or_else(|| dbus_call::<bool>("org.freedesktop.ScreenSaver", "/ScreenSaver", "org.freedesktop.ScreenSaver", "IsActive"))
.unwrap_or(false)
}

View File

@ -60,6 +60,18 @@ pub fn db(samples: &[f32]) -> f64 {
if rms <= 1e-8 { -160.0 } else { 20.0 * rms.log10() }
}
/// Append the mono mix of `data` to the buffer. Runs inside cpal's real-time callback, so it
/// only takes the lock with `try_lock` (blocking there starves the audio device → xruns and
/// crackly audio; the reader sleeps 100 ms between looks, so a skipped block is rare and
/// harmless) and does no per-sample allocation.
fn mix<T: Copy>(data: &[T], channels: usize, buf: &Arc<Mutex<Vec<f32>>>, to_f32: impl Fn(T) -> f32) {
let Ok(mut g) = buf.try_lock() else { return };
g.reserve(data.len() / channels.max(1));
for frame in data.chunks(channels) {
g.push(frame.iter().map(|s| to_f32(*s)).sum::<f32>() / frame.len() as f32);
}
}
fn run(file: PathBuf, listen: Option<Listen>, max_secs: u64, stop: Arc<AtomicBool>) -> &'static str {
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
let host = cpal::default_host();
@ -69,27 +81,12 @@ fn run(file: PathBuf, listen: Option<Listen>, max_secs: u64, stop: Arc<AtomicBoo
let channels = cfg.channels().max(1) as usize;
let buf: Arc<Mutex<Vec<f32>>> = Arc::new(Mutex::new(Vec::new()));
let b = buf.clone();
let mono = move |data: &[f32]| {
let mut g = b.lock().unwrap();
for frame in data.chunks(channels) {
g.push(frame.iter().sum::<f32>() / frame.len() as f32);
}
};
let err = |_| {};
let stream = match cfg.sample_format() {
cpal::SampleFormat::F32 => device.build_input_stream(cfg.config(), move |d: &[f32], _: &_| mono(d), err, None),
cpal::SampleFormat::I16 => {
let m = mono;
device.build_input_stream(cfg.config(), move |d: &[i16], _: &_| m(&d.iter().map(|s| *s as f32 / 32768.0).collect::<Vec<_>>()), err, None)
}
cpal::SampleFormat::I32 => {
let m = mono;
device.build_input_stream(cfg.config(), move |d: &[i32], _: &_| m(&d.iter().map(|s| *s as f32 / 2_147_483_648.0).collect::<Vec<_>>()), err, None)
}
cpal::SampleFormat::U8 => {
let m = mono;
device.build_input_stream(cfg.config(), move |d: &[u8], _: &_| m(&d.iter().map(|s| (*s as f32 - 128.0) / 128.0).collect::<Vec<_>>()), err, None)
}
cpal::SampleFormat::F32 => device.build_input_stream(cfg.config(), move |d: &[f32], _: &_| mix(d, channels, &b, |x| x), err, None),
cpal::SampleFormat::I16 => device.build_input_stream(cfg.config(), move |d: &[i16], _: &_| mix(d, channels, &b, |x| x as f32 / 32768.0), err, None),
cpal::SampleFormat::I32 => device.build_input_stream(cfg.config(), move |d: &[i32], _: &_| mix(d, channels, &b, |x| x as f32 / 2_147_483_648.0), err, None),
cpal::SampleFormat::U8 => device.build_input_stream(cfg.config(), move |d: &[u8], _: &_| mix(d, channels, &b, |x| (x as f32 - 128.0) / 128.0), err, None),
_ => return "fail",
};
let Ok(stream) = stream else { return "fail" };

View File

@ -34,7 +34,9 @@ const PAIRS: &[(&str, &str)] = &[
/// Mac wording → this system's wording (unchanged on macOS).
pub fn localize(s: &str) -> String {
if cfg!(target_os = "macos") || !s.contains("Mac") && !s.contains("Finder") {
// "macOS Keychain" and "macOS 鑰匙圈" contain no "Mac" (lowercase m), so the quick
// guard has to test "macOS" as well — those pairs used to never apply.
if cfg!(target_os = "macos") || !s.contains("Mac") && !s.contains("macOS") && !s.contains("Finder") {
return s.to_string();
}
let mut out = s.to_string();
@ -75,9 +77,14 @@ mod tests {
fn dialog(zenity: &[&str], kdialog: &[&str]) -> Option<String> {
for (cmd, args) in [("zenity", zenity), ("kdialog", kdialog)] {
if let Ok(o) = std::process::Command::new(cmd).args(args).stderr(std::process::Stdio::null()).output() {
match std::process::Command::new(cmd).args(args).stderr(std::process::Stdio::null()).output() {
// Exit code 1 = the person pressed Cancel: don't try the other one.
return o.status.success().then(|| String::from_utf8_lossy(&o.stdout).trim_end_matches('\n').to_string());
Ok(o) if o.status.success() => return Some(String::from_utf8_lossy(&o.stdout).trim_end_matches('\n').to_string()),
Ok(o) if o.status.code() == Some(1) => return None,
// Missing binary (Err) or a tool error (zenity 5, kdialog 2): fall through to
// the other tool — the old code returned None on any spawn, so a broken zenity
// hid the working kdialog.
_ => continue,
}
}
None

View File

@ -39,6 +39,7 @@ static EVENTS: Mutex<Vec<Raw>> = Mutex::new(Vec::new());
static EVENT_FRAMES: Mutex<Vec<usize>> = Mutex::new(Vec::new());
static FRAMES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
static FRAME_STOP: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static FRAME_HANDLE: Mutex<Option<std::thread::JoinHandle<()>>> = Mutex::new(None);
static REC_DIR: Mutex<Option<std::path::PathBuf>> = Mutex::new(None);
/// Screen pictures per second while recording (estimated; the original records video).
pub const FRAMES_PER_SEC: u64 = 2;
@ -59,6 +60,10 @@ pub fn start(bot_name: &str) -> Result<(), String> {
}
EVENTS.lock().unwrap().clear();
EVENT_FRAMES.lock().unwrap().clear();
// Zero the frame counter before the listener starts: events that arrive in the first
// half second used to carry the *previous* recording's frame numbers (start_frames
// cleared it only after mac::start), mapping steps to pictures from an old demo.
FRAMES.store(0, std::sync::atomic::Ordering::SeqCst);
mac::start()?;
*RECORDING.lock().unwrap() = Some((bot_name.to_string(), Instant::now()));
start_frames();
@ -78,7 +83,7 @@ fn start_frames() {
*REC_DIR.lock().unwrap() = Some(dir.clone());
FRAMES.store(0, std::sync::atomic::Ordering::SeqCst);
FRAME_STOP.store(false, std::sync::atomic::Ordering::SeqCst);
std::thread::spawn(move || {
*FRAME_HANDLE.lock().unwrap() = Some(std::thread::spawn(move || {
while !FRAME_STOP.load(std::sync::atomic::Ordering::SeqCst) {
let t0 = Instant::now();
if crate::computer::screen_supported() && crate::computer::screen_capture_ok() {
@ -98,16 +103,38 @@ fn start_frames() {
std::thread::sleep(rest);
}
}
});
}));
}
/// Stop the pictures and turn them into a video when ffmpeg is there. Returns (dir, video).
fn stop_frames() -> Option<(std::path::PathBuf, String)> {
FRAME_STOP.store(true, std::sync::atomic::Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(700));
// Let the capturing thread finish its current picture and exit (it ends within a second).
// The old fixed 700 ms sleep raced slow captures (dirty files after the video was made)
// and froze the window for its whole duration.
if let Some(h) = FRAME_HANDLE.lock().unwrap().take() {
let _ = h.join();
}
let dir = REC_DIR.lock().unwrap().take()?;
let video = make_video(&dir).unwrap_or_default();
Some((dir, video))
// Encoding runs off the window thread (ffmpeg on a long demo takes seconds; the window
// used to freeze for all of it). The mp4 file appears when it is done.
if dir.join("frame_00000.jpg").exists() {
let d = dir.clone();
std::thread::spawn(move || {
if make_video(&d).is_some() {
// The video holds the motion; drop the single pictures it consumed.
if let Ok(rd) = std::fs::read_dir(&d) {
for e in rd.flatten() {
if e.file_name().to_string_lossy().starts_with("frame_") {
let _ = std::fs::remove_file(e.path());
}
}
}
}
});
return Some((dir.clone(), dir.join("recording.mp4").display().to_string()));
}
Some((dir, String::new()))
}
/// frame_*.jpg → recording.mp4 (H.264, like the original's render proxy).
@ -134,6 +161,9 @@ pub fn make_video(dir: &std::path::Path) -> Option<String> {
/// Stop and write up the steps.
pub fn finish(name: &str) -> Demo {
mac::stop();
// The listener stops asynchronously (the X11 one polls every 150 ms): give the last
// events of the demo time to arrive — they used to be cut off the recording.
std::thread::sleep(std::time::Duration::from_millis(300));
*RECORDING.lock().unwrap() = None;
let raw = std::mem::take(&mut *EVENTS.lock().unwrap());
let frames_at = std::mem::take(&mut *EVENT_FRAMES.lock().unwrap());
@ -150,16 +180,7 @@ pub fn finish(name: &str) -> Demo {
let dst = dir.join(format!("step_{:02}.jpg", i + 1));
shots.push(if std::fs::copy(&src, &dst).is_ok() { dst.display().to_string() } else { String::new() });
}
// The video holds the rest.
if !video.is_empty() {
if let Ok(rd) = std::fs::read_dir(dir) {
for e in rd.flatten() {
if e.file_name().to_string_lossy().starts_with("frame_") {
let _ = std::fs::remove_file(e.path());
}
}
}
}
// The frame pictures are removed by the encoder thread once the video is written.
}
Demo { name: name.trim().to_string(), created: chrono::Local::now().format("%Y-%m-%d %H:%M").to_string(), steps: compiled.into_iter().map(|c| c.0).collect(), video, shots }
}
@ -382,6 +403,21 @@ mod mac {
}
static TAP: Mutex<usize> = Mutex::new(0);
// The foreground app barely moves between keystrokes, and the window-list query is the
// slowest call in the tap callback — slow callbacks make macOS disable the tap (events
// lost until it is re-enabled). Answer keystrokes from a short cache.
static FRONT_CACHE: Mutex<Option<(Instant, String)>> = Mutex::new(None);
fn front_cached() -> String {
let mut c = FRONT_CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some((t, n)) = c.as_ref() {
if t.elapsed() < std::time::Duration::from_millis(400) {
return n.clone();
}
}
let n = crate::computer::frontmost_app().unwrap_or_default();
*c = Some((Instant::now(), n.clone()));
n
}
extern "C" fn callback(_proxy: *mut c_void, kind: u32, e: *mut c_void, _user: *mut c_void) -> *mut c_void {
unsafe {
@ -397,7 +433,7 @@ mod mac {
CGEventKeyboardGetUnicodeString(e, buf.len(), &mut n, buf.as_mut_ptr());
let text = String::from_utf16_lossy(&buf[..n.min(buf.len())]);
let code = CGEventGetIntegerValueField(e, 9);
let app = crate::computer::frontmost_app().unwrap_or_default();
let app = front_cached();
push(Raw::Key { text, code, flags: CGEventGetFlags(e), app });
}
22 => {
@ -445,7 +481,7 @@ mod mac {
match rx.recv_timeout(std::time::Duration::from_secs(3)) {
Ok(true) => Ok(()),
_ => {
let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent").spawn();
let _ = crate::computer::spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"));
Err("macOS 還沒讓 AnyBot 觀看你的操作。我已經打開「系統設定 → 隱私權與安全性 → 輸入監控」:把 AnyBot(或「終端機」)打開,再按一次「開始錄製」。".into())
}
}
@ -491,7 +527,17 @@ mod mac {
let _ = tx.send(Err(e));
}
});
rx.recv_timeout(std::time::Duration::from_secs(3)).unwrap_or(Err("沒辦法開始錄製(X11 沒有回應)".into()))
// On a timeout the record thread may still be spinning up: close the gate so its
// keep_going() ends it — a thread left running meant a second RECORD context
// (double events) on the next try, plus a leaked X connection.
match rx.recv_timeout(std::time::Duration::from_secs(3)) {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(e),
Err(_) => {
STOP.store(true, Ordering::SeqCst);
Err("沒辦法開始錄製(X11 沒有回應)".into())
}
}
}
/// X input → the same events the macOS tap gives (key codes and flags in macOS terms, so