From 21b917b0eb85ea3468bc29e406f78564e479c20a 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] Demo recording keeps its last events off the UI thread; dialogs, screensaver probe and installer traps fixed --- Makefile | 7 +++-- install.sh | 45 ++++++++++++++++++++++++--- src/desk.rs | 6 +++- src/mic.rs | 35 ++++++++++----------- src/platform.rs | 13 ++++++-- src/teach.rs | 82 ++++++++++++++++++++++++++++++++++++++----------- 6 files changed, 140 insertions(+), 48 deletions(-) diff --git a/Makefile b/Makefile index e40ee34..97bfa9c 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/install.sh b/install.sh index cff4f5c..7a58d2f 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/src/desk.rs b/src/desk.rs index c0113ee..246184b 100644 --- a/src/desk.rs +++ b/src/desk.rs @@ -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::("org.gnome.ScreenSaver", "/org/gnome/ScreenSaver", "org.gnome.ScreenSaver", "GetActive") - .or_else(|| dbus_call::("org.freedesktop.ScreenSaver", "/org/freedesktop/ScreenSaver", "org.freedesktop.ScreenSaver", "GetActive")) + .or_else(|| dbus_call::("org.freedesktop.ScreenSaver", "/ScreenSaver", "org.freedesktop.ScreenSaver", "IsActive")) .unwrap_or(false) } diff --git a/src/mic.rs b/src/mic.rs index 012b794..e621310 100644 --- a/src/mic.rs +++ b/src/mic.rs @@ -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(data: &[T], channels: usize, buf: &Arc>>, 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::() / frame.len() as f32); + } +} + fn run(file: PathBuf, listen: Option, max_secs: u64, stop: Arc) -> &'static str { use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; let host = cpal::default_host(); @@ -69,27 +81,12 @@ fn run(file: PathBuf, listen: Option, max_secs: u64, stop: Arc>> = 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::() / 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::>()), 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::>()), 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::>()), 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" }; diff --git a/src/platform.rs b/src/platform.rs index cf11594..48ffa2f 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -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 { 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 diff --git a/src/teach.rs b/src/teach.rs index f674b7a..acb2bc3 100644 --- a/src/teach.rs +++ b/src/teach.rs @@ -39,6 +39,7 @@ static EVENTS: Mutex> = Mutex::new(Vec::new()); static EVENT_FRAMES: Mutex> = 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>> = Mutex::new(None); static REC_DIR: Mutex> = 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 { /// 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 = 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> = 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