Demo recording keeps its last events off the UI thread; dialogs, screensaver probe and installer traps fixed
This commit is contained in:
parent
a8265409a1
commit
21b917b0eb
7
Makefile
7
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
|
||||
|
|
|
|||
45
install.sh
45
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
35
src/mic.rs
35
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<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" };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
82
src/teach.rs
82
src/teach.rs
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue