Merge pull request 'Audit fixes: sync chain, box, desktop, install, UI (B1-B30, F1-F5)' (#5) from brain-computers into main

Reviewed-on: #5
This commit is contained in:
王性驊 2026-11-05 07:52:28 +00:00
commit 9d8f7bbead
48 changed files with 1780 additions and 316 deletions

1
Cargo.lock generated
View File

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

View File

@ -10,6 +10,7 @@ name = "anybot"
path = "src/main.rs"
[dependencies]
zhconv = "0.4"
eframe = "0.37"
ureq = { version = "3", features = ["json"] }
# 長連線 to the account server (WebSocket over the same TLS ureq uses), and compressing what goes over it.
@ -48,7 +49,6 @@ agent-desktop-core = { git = "https://github.com/vercel-labs/agent-desktop", tag
opt-level = 2
[target.'cfg(not(target_os = "macos"))'.dependencies]
zhconv = "0.4"
[target.'cfg(target_os = "linux")'.dependencies]
agent-desktop-linux = { git = "https://github.com/vercel-labs/agent-desktop", tag = "v1.1.0" }
@ -56,6 +56,8 @@ x11rb = { version = "0.13", features = ["xtest", "screensaver", "record"] }
# Wayland: wlroots desktops (sway, Hyprland…) through their own protocols; GNOME and KDE through
# the desktop portals (one "allow" dialog, remembered).
wayland-client = "0.31"
# (wl.rs: SO_RCVTIMEO on the compositor socket, so a frozen compositor can't freeze the app)
libc = "0.2"
wayland-protocols = { version = "0.32", features = ["client", "staging", "unstable"] }
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
wayland-protocols-misc = { version = "0.3", features = ["client"] }

View File

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

@ -8,12 +8,36 @@ cp /etc/anybot/menu /home/bot/.fluxbox/menu 2>/dev/null
chown -R bot:bot /home/bot
rm -f /tmp/.X1-lock /tmp/.X11-unix/X1
Xvfb :1 -screen 0 "${SCREEN_SIZE}" -nolisten tcp &
XVFB_PID=$!
for i in $(seq 1 50); do xdpyinfo -display :1 >/dev/null 2>&1 && break; sleep 0.1; done
su bot -c "DISPLAY=:1 xrdb -nocpp -merge /home/bot/.Xresources"
su bot -c "DISPLAY=:1 fluxbox >/tmp/fluxbox.log 2>&1 &"
(sleep 1.5; xsetroot -display :1 -solid '#2b2f36') &
xsetroot -display :1 -solid '#2b2f36'
# 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_PID=$!
# Say ready only once the viewer's port listens: the app opens noVNC on this flag, and a too
# early file meant the first viewer load 404'd.
(
for i in $(seq 1 100); do
python3 -c "import socket; socket.create_connection(('127.0.0.1', 6080), 0.5)" 2>/dev/null && break
sleep 0.2
done
echo "anybot-box ready" > /tmp/anybot-box.ready
wait
) &
# When the desktop's heart stops, the container ends too (it used to sit there as Running
# with a dead X server, and every screen action failed forever).
wait $XVFB_PID
kill $WEBSOCKIFY_PID 2>/dev/null
pkill -f x11vnc 2>/dev/null
pkill -u bot fluxbox 2>/dev/null
exit 1

View File

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

@ -388,6 +388,11 @@ async function pickHost() {
const hosts = all.filter((h) => !(h.uses && byId[h.uses]));
const sharers = (id) => all.filter((h) => h.uses === id).map((h) => h.name);
const live = hosts.filter((h) => h.online);
// Several brains on: the one this account's computers use is the one the chats are on.
const used = live.filter((h) => sharers(h.id).length);
// First visit picks the brain this account's computers use; a choice the person made (or the
// host stored from last time) is kept as it is.
if (!S.host && used.length === 1) S.host = used[0].id;
if (S.host) {
const t = target(S.host);
if (byId[t] && byId[t].online) { S.host = t; localStorage.setItem("anybot.host", t); return start(); }

View File

@ -36,6 +36,9 @@ const ICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 9
const HOST_POLL_SECS: u64 = 25;
/// How long a phone's request waits for the AnyBot to answer (its own long-poll is 20 s).
const FORWARD_SECS: u64 = 35;
/// Model requests relayed to a person's computer (model_fetch) are ordinary LLM latency:
/// the asking side waits minutes, so the relay must not answer first with a timeout.
const LONG_FORWARD_SECS: u64 = 610;
/// How long a 配對碼 lasts.
const PAIR_SECS: u64 = 600;
/// How long a request may wait to be picked up before the host counts as gone.
@ -186,7 +189,9 @@ struct Relay {
queues: Mutex<HashMap<String, VecDeque<Value>>>,
arrived: Condvar,
/// Where each forwarded request's answer goes.
waiting: Mutex<HashMap<String, Sender<Answer>>>,
/// Requests waiting for an answer: job id → (its answer, the host it was sent to —
/// so only that host may answer it).
waiting: Mutex<HashMap<String, (Sender<Answer>, String)>>,
/// AnyBots connected the lasting way (WebSocket), and which requests went over which.
sockets: Mutex<HashMap<String, Sock>>,
flights: Mutex<HashMap<String, (String, u64)>>,
@ -301,15 +306,16 @@ impl Relay {
return Answer::error(503, OFFLINE);
}
let id = httpd::random_hex(12);
let secs = if path == "/api/device/run" { LONG_FORWARD_SECS } else { FORWARD_SECS };
let (tx, rx) = channel();
self.waiting.lock().unwrap().insert(id.clone(), tx);
self.waiting.lock().unwrap().insert(id.clone(), (tx, host_id.to_string()));
let head = json!({"t": "job", "id": id, "method": method, "path": path, "query": query, "user": who.0, "role": who.1, "z": z, "z_ok": z_ok, "from": from});
// Connected the lasting way: straight down that connection.
let sock = self.sockets.lock().unwrap().get(host_id).map(|s| (s.tx.clone(), s.serial));
if let Some((stx, serial)) = sock {
self.flights.lock().unwrap().insert(id.clone(), (host_id.to_string(), serial));
let sent = stx.send(wire::pack(&head, body)).is_ok();
let r = if sent { rx.recv_timeout(Duration::from_secs(FORWARD_SECS)).ok() } else { None };
let r = if sent { rx.recv_timeout(Duration::from_secs(secs)).ok() } else { None };
self.waiting.lock().unwrap().remove(&id);
self.flights.lock().unwrap().remove(&id);
return match r {
@ -328,7 +334,7 @@ impl Relay {
if r.is_err() {
let queued = self.queues.lock().unwrap().get(host_id).map(|q| q.iter().any(|j| j["id"].as_str() == Some(id.as_str()))).unwrap_or(false);
if !queued {
r = rx.recv_timeout(Duration::from_secs(FORWARD_SECS - PICKUP_SECS));
r = rx.recv_timeout(Duration::from_secs(secs.max(PICKUP_SECS + 1) - PICKUP_SECS));
}
}
self.waiting.lock().unwrap().remove(&id);
@ -416,7 +422,7 @@ impl Relay {
}
let lost: Vec<String> = self.flights.lock().unwrap().iter().filter(|(_, (host, s))| *host == h.id && *s == serial).map(|(id, _)| id.clone()).collect();
for id in lost {
if let Some(w) = self.waiting.lock().unwrap().remove(&id) {
if let Some((w, _)) = self.waiting.lock().unwrap().remove(&id) {
let _ = w.send(Answer::error(503, GONE));
}
}
@ -427,11 +433,18 @@ impl Relay {
let s = |k: &str| head[k].as_str().unwrap_or("").to_string();
match head["t"].as_str().unwrap_or("") {
"reply" => {
if let Some(w) = self.waiting.lock().unwrap().remove(&s("id")) {
// Only the host the job went to may answer it.
let w = {
let mut waiting = self.waiting.lock().unwrap();
match waiting.get(&s("id")) {
Some((_, target)) if *target == h.id => waiting.remove(&s("id")).map(|(w, _)| w),
_ => None,
}
};
let Some(w) = w else { return };
let headers = head["headers"].as_array().into_iter().flatten().filter_map(|x| Some((x[0].as_str()?.to_string(), x[1].as_str()?.to_string()))).collect();
let _ = w.send(Answer { status: head["status"].as_u64().unwrap_or(502) as u16, ctype: s("ctype"), headers, body, z: head["z"].as_bool().unwrap_or(false) });
}
}
// One AnyBot asks another of the same account (大腦 sync, 裝置連結).
"call" => {
let (id, to, path) = (s("id"), s("to"), s("path"));
@ -620,10 +633,16 @@ impl Relay {
}
}
("POST", ["relay", "host", "reply", id]) => {
if self.host(&req).is_none() {
return Resp::error(401, "這台電腦的金鑰無效");
let Some(h) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") };
// Only the host the job was sent to may answer it.
let tx = {
let mut waiting = self.waiting.lock().unwrap();
match waiting.get(*id) {
Some((_, target)) if *target == h.id => waiting.remove(*id).map(|(w, _)| w),
_ => None,
}
if let Some(tx) = self.waiting.lock().unwrap().remove(*id) {
};
if let Some(tx) = tx {
let _ = tx.send(Answer::from_json(&req.json()));
}
Resp::json(&json!({"ok": true}))
@ -724,8 +743,26 @@ impl Relay {
let d = self.data.lock().unwrap();
Resp::json(&json!({"hosts": d.hosts.iter().filter(|h| h.user == me.user).map(|h| self.host_json(h)).collect::<Vec<_>>()}))
}
// Signing out of the app unregisters this machine: its own key removes its own
// registration, so a key left on a borrowed computer dies with the sign-out.
("POST", ["relay", "host", "remove"]) => {
let Some(h) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") };
{
let mut d = self.data.lock().unwrap();
d.hosts.retain(|x| x.id != h.id);
self.save(&d);
}
// Its open connection is closed, so it hears "the key is gone" at once.
if let Some(s) = self.sockets.lock().unwrap().remove(&h.id) {
let _ = s.tx.send(wire::pack(&json!({"t": "bye"}), &[]));
}
Resp::json(&json!({"ok": true}))
}
("PUT", ["relay", "backup"]) => {
let Some(h) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") };
if req.body.len() > 200 * 1024 * 1024 {
return Resp::error(413, "備份太大了(最多 200 MB)");
}
let path = self.dir.join("backups").join(format!("{}.bin", httpd::sha256_hex(&h.user)));
let tmp = path.with_extension("tmp");
match std::fs::write(&tmp, &req.body).and_then(|_| std::fs::rename(&tmp, &path)) {
@ -820,7 +857,14 @@ impl Relay {
fn login(&self, req: &Req) -> Resp {
let v = req.json();
let user = v["user"].as_str().unwrap_or("").trim().to_lowercase();
let key = format!("{}|{user}", req.header("x-forwarded-for").split(',').next().unwrap_or(&req.peer).trim());
// Rate-limit by the real peer. A client writes its own first X-Forwarded-For entry, so
// taking the first value lets anyone reset the counter by changing the header. Only when
// RELAY_TRUST_PROXY=1 (behind a reverse proxy) use the proxy's appended (last) entry.
let ip = match (std::env::var("RELAY_TRUST_PROXY").is_ok(), req.header("x-forwarded-for")) {
(true, xff) if !xff.is_empty() => xff.rsplit(',').next().unwrap_or(xff).trim().to_string(),
_ => req.peer.clone(),
};
let key = format!("{ip}|{user}");
if self.too_many_fails(&key) {
return Resp::error(429, "密碼錯太多次,請 5 分鐘後再試");
}
@ -1121,4 +1165,94 @@ mod tests {
assert_eq!(post(port, "/relay/pair/claim", "", json!({"code": "000000", "name": "x"})).0, 429);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn brain_mirror_link_answers_only_from_the_right_machine() {
// One account with a brain, a hands machine, and a nosy third machine: the sync
// channel (brain → relay → hands → relay → brain) must carry the right roles, must
// take answers only from the machine a job went to, and a signing-out machine's key
// must die with its registration.
let dir = std::env::temp_dir().join(format!("anybot-relay-brain-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let relay = Arc::new(Relay::load(dir.clone()));
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
let r = relay.clone();
std::thread::spawn(move || httpd::serve(l, Arc::new(move |req| r.handle(req)), Arc::new(|| true)));
post(port, "/relay/signup", "", json!({"user": "boss", "password": "12345678"}));
let s = post(port, "/relay/login", "", json!({"user": "boss", "password": "12345678", "device": "phone"})).1["token"].as_str().unwrap().to_string();
let hb = post(port, "/relay/hosts", &s, json!({"name": "大腦"})).1;
let btoken = hb["token"].as_str().unwrap().to_string();
let hh = post(port, "/relay/hosts", &s, json!({"name": "手腳"})).1;
let (hid, htoken) = (hh["id"].as_str().unwrap().to_string(), hh["token"].as_str().unwrap().to_string());
let hm = post(port, "/relay/hosts", &s, json!({"name": "旁聽"})).1;
let mtoken = hm["token"].as_str().unwrap().to_string();
// The hands machine: takes its jobs, lets the snooper see the id, answers a beat later.
let (tx, rx) = channel::<String>();
let ht = htoken.clone();
std::thread::spawn(move || loop {
let (st, body) = get(port, "/relay/host/next", &ht);
if st == 200 {
let job: Value = serde_json::from_slice(&body).unwrap();
let id = job["id"].as_str().unwrap().to_string();
let _ = tx.send(id.clone());
std::thread::sleep(Duration::from_millis(400));
let answer = json!({"status": 200, "ctype": "application/json", "body": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, json!({"from": "hands", "role": job["role"], "path": job["path"]}).to_string()), "headers": []});
post(port, &format!("/relay/host/reply/{id}"), &ht, answer);
}
});
// The snooper answers with a lie as soon as it learns a job id.
let sn = mtoken.clone();
std::thread::spawn(move || {
let id = rx.recv().unwrap();
let lie = json!({"status": 200, "ctype": "application/json", "body": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, json!({"from": "snooper"}).to_string()), "headers": []});
post(port, &format!("/relay/host/reply/{id}"), &sn, lie);
});
std::thread::sleep(Duration::from_millis(200));
// 大腦 syncs 手腳's chats: the answer must come from the hands, with the mirror role.
let v: Value = serde_json::from_slice(&get(port, &format!("/relay/hosts/{hid}/api/sync?since=0"), &btoken).1).unwrap();
assert_eq!((v["from"].as_str(), v["role"].as_str(), v["path"].as_str()), (Some("hands"), Some("mirror"), Some("/api/sync")), "the brain's sync answer must be the hands' own");
// 裝置連結: running a tool on the other machine carries the device role.
let v: Value = post(port, &format!("/relay/hosts/{hid}/api/device/run"), &btoken, json!({"tool": "shell", "args": "{}", "who": "大腦"})).1;
assert_eq!((v["from"].as_str(), v["role"].as_str()), (Some("hands"), Some("device")));
// Signing out: the machine's own key removes its registration; others can't reach it.
assert_eq!(post(port, "/relay/host/remove", &mtoken, json!({})).0, 200);
let hosts: Value = serde_json::from_slice(&get(port, "/relay/host/peers", &btoken).1).unwrap();
assert_eq!(hosts["hosts"].as_array().map(|a| a.len()), Some(2));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn forged_forwarded_for_cannot_outrun_the_login_lock() {
// The login throttle follows the real peer: rewriting the first X-Forwarded-For
// entry on every try used to reset the counter, so passwords could be guessed forever.
let dir = std::env::temp_dir().join(format!("anybot-relay-throttle-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let relay = Arc::new(Relay::load(dir.clone()));
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
let r = relay.clone();
std::thread::spawn(move || httpd::serve(l, Arc::new(move |req| r.handle(req)), Arc::new(|| true)));
post(port, "/relay/signup", "", json!({"user": "boss", "password": "12345678"}));
let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into();
for i in 0..8 {
let mut rr = agent
.post(&format!("http://127.0.0.1:{port}/relay/login"))
.header("X-Forwarded-For", &format!("10.9.{i}.{i}"))
.send(json!({"user": "boss", "password": "wrong-password"}).to_string().as_bytes())
.unwrap();
let _ = rr.body_mut().read_to_string();
}
// Every fake IP was new; the one real peer is locked out — even for the right password.
let rr = agent
.post(&format!("http://127.0.0.1:{port}/relay/login"))
.header("X-Forwarded-For", "10.9.99.99")
.send(json!({"user": "boss", "password": "12345678"}).to_string().as_bytes())
.unwrap();
assert_eq!(rr.status().as_u16(), 429);
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -84,7 +84,8 @@ fn review_action(job: &Job, review: &Review, action: &str, cancel: &AtomicBool)
};
let Ok(out) = providers::run_step(&req, cancel, &|_| {}) else { return ReviewVerdict::NoMatch };
let text = out.text.trim();
let json_part = text.find('{').and_then(|a| text.rfind('}').map(|b| &text[a..=b])).unwrap_or(text);
// A truncated answer can put the last "}" before the first "{" — slicing then panics.
let json_part = text.find('{').and_then(|a| text.rfind('}').filter(|b| *b >= a).map(|b| &text[a..=b])).unwrap_or(text);
let v: serde_json::Value = serde_json::from_str(json_part).unwrap_or_default();
let rule = v["rule"].as_str().unwrap_or("").to_string();
match v["verdict"].as_str() {
@ -114,6 +115,101 @@ fn target_for(job: &Job, args: &str) -> Result<tools::Target, String> {
})
}
enum BrowserAt {
Here,
Box,
Device(String),
}
/// Whose browser a browser step uses: the computer it names, else the bot's chosen computer.
fn browser_place(job: &Job, name: &str, args: &str) -> BrowserAt {
if name == "web_fetch" {
return BrowserAt::Here;
}
let v = tools::args_of(args);
let want = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty()).unwrap_or(&job.default_computer).to_string();
let any_device = || job.computers.iter().find_map(|t| match &t.place {
tools::Place::Device { host } => Some(host.clone()),
_ => None,
});
match job.computers.iter().find(|t| t.name == want).map(|t| &t.place) {
Some(tools::Place::Device { host }) => BrowserAt::Device(host.clone()),
Some(tools::Place::Box { .. }) if crate::cloudbox::available() => BrowserAt::Box,
// No browser here (a brain on a server): the 手腳 computer's.
_ if !crate::botbrowser::available() => any_device().map(BrowserAt::Device).unwrap_or(BrowserAt::Here),
_ => BrowserAt::Here,
}
}
/// Substring search with word boundaries on the ASCII parts: "mac" must not light up inside
/// "macOS", and a device named "Ann" must not light up on "anniversary". Loose matching let
/// unrelated words — including words from page text that arrives inside a message — unlock a
/// computer the person never named, defeating the guard below.
fn finds(hay: &str, needle: &str) -> bool {
let hb: Vec<char> = hay.chars().collect();
let nb: Vec<char> = needle.chars().collect();
if nb.is_empty() || nb.len() > hb.len() {
return false;
}
let alnum = |c: char| c.is_ascii_alphanumeric();
'start: for i in 0..=hb.len() - nb.len() {
if hb[i] != nb[0] {
continue;
}
for j in 1..nb.len() {
if hb[i + j] != nb[j] {
continue 'start;
}
}
if alnum(nb[0]) && i > 0 && alnum(hb[i - 1]) {
continue;
}
if alnum(*nb.last().unwrap()) && i + nb.len() < hb.len() && alnum(hb[i + nb.len()]) {
continue;
}
return true;
}
false
}
/// Words that mean a computer in the person's message.
fn mentions(text: &str, t: &tools::Target) -> bool {
let low = text.to_lowercase();
if finds(&low, &t.name.to_lowercase()) {
return true;
}
let words: &[&str] = match &t.place {
_ if t.name == crate::model::BOT_COMPUTER_NAME => &["bot 的電腦", "bot的電腦", "你自己的電腦", "你的虛擬", "虛擬電腦", "雲端電腦"],
_ if t.name == crate::model::THIS_COMPUTER_NAME => &["mac", "我的電腦", "我電腦", "我這台", "這台電腦", "本機", "我的筆電"],
tools::Place::Device { host } => return !crate::remote::peer_name(host).is_empty() && finds(&low, &crate::remote::peer_name(host).to_lowercase()),
_ => &[],
};
words.iter().any(|w| finds(&low, w))
}
/// A step naming a computer other than the chosen one, which the person didn't ask for, goes
/// to the chosen one.
fn keep_to_chosen(job: &Job, history: &[Message], args: &str) -> String {
let mut v = tools::args_of(args);
let Some(named) = v.get("computer").and_then(|c| c.as_str()).map(|c| c.trim().to_string()).filter(|c| !c.is_empty()) else { return args.to_string() };
if named == job.default_computer || !job.computers.iter().any(|t| t.name == job.default_computer) {
return args.to_string();
}
let said = history.iter().rev().filter(|m| m.role == Role::User && !m.summary).take(3).map(|m| m.text.as_str()).collect::<Vec<_>>().join("\n");
match job.computers.iter().find(|t| t.name == named) {
Some(t) if mentions(&said, t) => args.to_string(),
_ => {
v["computer"] = serde_json::Value::String(job.default_computer.clone());
v.to_string()
}
}
}
/// Does the call name a computer?
fn names_a_computer(args: &str) -> bool {
tools::args_of(args).get("computer").and_then(|c| c.as_str()).map(|c| !c.trim().is_empty()).unwrap_or(false)
}
/// The other computer (裝置連結) a call names, if it names one.
fn device_named(job: &Job, args: &str) -> Option<String> {
let v = tools::args_of(args);
@ -235,8 +331,11 @@ fn maybe_compact(job: &Job, history: &mut Vec<Message>, percent: u64, cancel: &A
return None;
}
let summary = Message { role: Role::Assistant, text: out.text.trim().to_string(), summary: true, via: job.via.clone(), at: crate::model::now_ms(), ..Default::default() };
// The window shows the chat unfiltered (group chats drop some messages on the way here);
// the timestamp of the first kept message is the landmark it can find the cut by.
let boundary = history[cut].at;
history.splice(..cut, [summary.clone()]);
send(StreamEvent::Compacted(cut, summary));
send(StreamEvent::Compacted(cut, summary, boundary));
Some(cut)
}
@ -283,9 +382,13 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
if cancel.load(Ordering::Relaxed) {
return;
}
let mut out = out;
for c in out.tool_calls.iter_mut() {
c.args = keep_to_chosen(&job, &history, &c.args);
}
let msg = Message {
role: Role::Assistant,
text: out.text,
text: crate::i18n::reply_text(&out.text),
thinking: out.thinking,
via: job.via.clone(),
tool_calls: out.tool_calls,
@ -304,6 +407,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
}
continue;
}
// (Each call's computer was already kept to the chosen one, see keep_to_chosen.)
for call in calls {
if appctl::is_app_tool(&call.name) {
// App actions run on the window's side, which also asks for deletions.
@ -342,12 +446,13 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
if call.name == "web_fetch" || (web::is_web_tool(&call.name) && !web::uses_this_computer(&call.name, &call.args)) {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
// 裝置連結: the AnyBot browser of another computer (its own logins).
let (output, ok, img) = match device_named(&job, &call.args).filter(|_| call.name != "web_fetch") {
Some(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) {
let (output, ok, img) = match browser_place(&job, &call.name, &call.args) {
BrowserAt::Device(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) {
Ok(v) => (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false), v["image"].as_str().map(String::from)),
Err(e) => (e, false, None),
},
None => web::run_full(&call.name, &call.args, &cancel),
BrowserAt::Box => crate::botbrowser::in_box(|| web::run_full(&call.name, &call.args, &cancel)),
BrowserAt::Here => web::run_full(&call.name, &call.args, &cancel),
};
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
let mut result = Message::tool_result(&call.id, &call.name, &output);
@ -398,12 +503,19 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
let path = tools::args_of(&call.args)["path"].as_str().unwrap_or("").to_string();
let local = match target_for(&job, &call.args) {
Ok(tools::Target { place: tools::Place::Box { dir }, .. }) => crate::cloudbox::copy_out(&if path.starts_with('/') { path.clone() } else { format!("{dir}/{path}") }),
// Files ride to the chat only from this bot's own folder on the box.
Ok(tools::Target { place: tools::Place::Box { dir }, .. }) => match tools::box_path(std::path::Path::new(&dir), &path) {
Ok(p) => crate::cloudbox::copy_out(&p),
Err(e) => Err(e),
},
Ok(tools::Target { place: tools::Place::Sealed { root }, .. }) => Ok(tools::resolve(&root, &path)),
Ok(tools::Target { place: tools::Place::Local { workdir }, .. }) => Ok(tools::resolve(&workdir, &path)),
Ok(_) => Err("這台電腦上的檔案不能直接傳,請先複製到 Bot 的電腦。".to_string()),
Err(e) => Err(e),
};
// Secrets (SSH keys, the app's own data folder, .env…) never ride into the chat,
// same rule as read_file/edit_file.
let local = local.and_then(|p| if tools::is_ignored(&p) || tools::is_ignored(std::path::Path::new(&path)) { Err("這個檔案是機密的,不能傳到聊天。".to_string()) } else { Ok(p) });
let (output, ok) = match local.and_then(|p| crate::attach::store(bot_id, &p)) {
Ok(a) => {
let out = format!("已把「{}」傳到聊天裡({}),使用者點一下就能預覽。", a.name, crate::attach::human(a.bytes));
@ -437,6 +549,8 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
(None, _) if path.starts_with("http://") || path.starts_with("https://") => Ok(std::path::PathBuf::from(&path)),
_ => Err("這台電腦上的檔案不能直接打開給使用者看,請先複製到 Bot 的電腦或這台 Mac。".to_string()),
};
// Secrets never open on the person's screen either.
let local = local.and_then(|p| if tools::is_ignored(&p) || tools::is_ignored(std::path::Path::new(&path)) { Err("這個檔案是機密的,不能打開。".to_string()) } else { Ok(p) });
let (output, ok) = match local {
Ok(p) if p.exists() || path.starts_with("http") => match crate::preview::system_open(&p).status() {
Ok(st) if st.success() => (format!("已在使用者的螢幕上打開 {}。", p.display()), true),
@ -625,11 +739,16 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
}
// The browser tools use the person's own browser, so always this Mac.
// …and so does screen control: it's the person's own screen.
let person_device = job.computers.iter().find(|t| t.name == crate::model::THIS_COMPUTER_NAME && matches!(t.place, tools::Place::Device { .. })).cloned();
let target = if is_screen {
screen_target(&job, &call.args)
} else if is_web && device_named(&job, &call.args).is_some() {
// The browser on another computer of the account, as named.
target_for(&job, &call.args)
} else if is_web && call.name.starts_with("browser_") && person_device.is_some() && !names_a_computer(&call.args) {
// A brain on a server: the browser is the person's computer's (the one they
// can see), unless the server's own is named.
Ok(person_device.clone().unwrap())
} else if is_web && call.name.starts_with("browser_") && !crate::botbrowser::available() && job.computers.iter().any(|t| matches!(t.place, tools::Place::Device { .. })) {
// No browser here (a brain on a server): the 手腳 computer's.
job.computers.iter().find(|t| matches!(t.place, tools::Place::Device { .. })).cloned().ok_or_else(|| "找不到這台電腦".to_string())
@ -869,6 +988,23 @@ mod tests {
use crate::model::ToolCall;
use crate::providers::ProviderKind;
#[test]
fn computer_words_need_word_boundaries() {
// "mac" as its own word names this computer; inside longer words (macOS, a device
// named Ann inside "anniversary") it must not — that is what made the guard fire on
// text the person never aimed at any computer.
assert!(finds("用我的 mac 執行", "mac"));
assert!(finds("mac 上開檔案", "mac"));
assert!(!finds("在 macOS 上開檔案", "mac"));
assert!(!finds("telegram 傳給我", "mac"));
assert!(finds("我的電腦好慢", "我的電腦"));
assert!(!finds("你的虛擬電腦很吵", "bot 的電腦"));
assert!(finds("在 bot 的電腦跑", "bot 的電腦"));
let t = tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: Default::default() } };
assert!(mentions("幫我在 Mac 截圖", &t));
assert!(!mentions("在 macOS 的登入機制下,幫 Bot 的電腦截圖", &t));
}
#[test]
fn cut_keeps_tool_pairs_and_recent_messages() {
let mut h = vec![];

View File

@ -690,14 +690,15 @@ impl App {
let mut tool_specs = appctl::specs();
tool_specs.extend(tools::specs_for(&names, &default_computer));
let devices: Vec<String> = computers.iter().filter(|t| matches!(t.place, tools::Place::Device { .. })).map(|t| t.name.clone()).collect();
tool_specs.extend(web::specs_for(&devices));
let here = computers.iter().find(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone()).unwrap_or_else(|| crate::model::THIS_COMPUTER_NAME.into());
tool_specs.extend(web::specs_for(&here, &devices));
// Screen tools: the bot's own desktop (when it has one) and this Mac.
let mut screens = vec![];
if crate::cloudbox::available() {
screens.push(crate::model::BOT_COMPUTER_NAME.to_string());
}
if crate::computer::screen_supported() {
screens.push(crate::model::THIS_COMPUTER_NAME.to_string());
screens.extend(computers.iter().filter(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone()));
}
// …and the account's other computers (手腳): their own AnyBot looks and clicks there.
screens.extend(devices.iter().cloned());
@ -804,9 +805,51 @@ impl App {
v.extend(self.state.remote_computers.iter().map(|c| tools::Target { name: c.name.clone(), place: tools::Place::Ssh { target: c.ssh_target.clone(), dir: c.dir.clone() } }));
// 裝置連結: other AnyBots of this account that allow it.
v.extend(self.linked_devices().into_iter().filter(|d| !v.iter().any(|t| t.name == d.name)).collect::<Vec<_>>());
// A brain on a server whose hands are the person's computer: "你的電腦" is that computer
// (what the person means by "my Mac"), and the server itself goes by its own name.
if !cfg!(target_os = "macos") {
if let Some(hand) = crate::remote::my_hands(&self.state.remote.host_id).into_iter().next() {
if let Some(i) = v.iter().position(|t| matches!(&t.place, tools::Place::Device { host } if *host == hand)) {
for t in v.iter_mut() {
if matches!(t.place, tools::Place::Local { .. }) {
t.name = crate::model::BRAIN_HOST_NAME.into();
}
}
v[i].name = crate::model::THIS_COMPUTER_NAME.into();
}
}
}
v
}
/// 查看電腦: the computers this bot really has, in plain words.
fn computers_text(&self, b: &Bot) -> String {
self.targets_for(b)
.iter()
.map(|t| match &t.place {
tools::Place::Sealed { .. } | tools::Place::Box { .. } => format!("{}:你自己的電腦,一律可用", t.name),
tools::Place::Local { .. } if t.name == crate::model::BRAIN_HOST_NAME => format!("{}:AnyBot 大腦所在的伺服器「{}」(不是使用者的電腦)", t.name, self.state.remote.host_name),
tools::Place::Local { .. } => format!(
"{}:{}「{}」,{}",
t.name,
if cfg!(target_os = "macos") { "使用者的這台 Mac" } else { "使用者的這台電腦" },
self.state.machine_label,
if self.state.local_execution { "已允許" } else { "還沒預先允許(直接用工具,App 會問使用者)" }
),
tools::Place::Ssh { target, .. } => format!("{}:透過 SSH 連到 {target}", t.name),
tools::Place::Device { host } => {
let real = crate::remote::peer_name(host);
if t.name == crate::model::THIS_COMPUTER_NAME {
format!("{}:使用者的電腦「{real}」(大腦透過它的 AnyBot 看畫面、點擊、開瀏覽器和跑指令)", t.name)
} else {
format!("{}:帳號裡的另一台電腦(裝置連結)", t.name)
}
}
})
.collect::<Vec<_>>()
.join("\n")
}
/// Record how a turn ended (success / cancelled / error) and clear its live state.
fn settle(&mut self, bot_id: u64, outcome: TurnOutcome) {
// 群組聊天: the next member answers after this one finishes well.
@ -900,6 +943,9 @@ impl App {
for id in std::mem::take(&mut self.group_next) {
if !self.streams.contains_key(&id) {
self.start_reply(ctx, id);
} else {
// Still busy: keep the turn queued instead of dropping it with the take().
self.group_next.push(id);
}
}
for (id, texts) in std::mem::take(&mut self.pending_turns) {
@ -908,12 +954,23 @@ impl App {
b.messages.push(Message::user(&t));
}
}
if self.streams.contains_key(&id) {
// A turn just settled in the same tick we queued this one; starting now would
// replace the running Live and let two agent threads write one chat. Retry idle.
self.group_next.push(id);
} else {
self.start_reply(ctx, id);
}
}
}
fn run_app_action(&mut self, ctx: &egui::Context, bot_id: u64, call: AppCall) {
let out = appctl::apply(&mut self.state, bot_id, &call.name, &call.args);
let out = if call.name == "list_computers" {
let text = self.state.bot(bot_id).cloned().map(|b| self.computers_text(&b)).unwrap_or_default();
appctl::Outcome { text, ok: true, wake: vec![], theme_changed: false, page: None }
} else {
appctl::apply(&mut self.state, bot_id, &call.name, &call.args)
};
// write_page: remember which page the call made, so its card shows under the reply.
if let Some(pid) = out.page {
if let Some(c) = self.state.bot_mut(bot_id).and_then(|b| b.messages.iter_mut().rev().take(12).flat_map(|m| m.tool_calls.iter_mut()).find(|c| c.id == call.call_id)) {
@ -1191,9 +1248,11 @@ impl App {
StreamEvent::Steered(m) => b.messages.push(m),
StreamEvent::NextStep(via) => b.messages.push(Message::assistant("", &via)),
StreamEvent::ToolResult(m) => b.messages.push(m),
StreamEvent::Compacted(n, summary) => {
let n = n.min(b.messages.len());
b.messages.splice(..n, [summary]);
StreamEvent::Compacted(n, summary, boundary) => {
// The model's history can be shorter than the window's chat (group chats
// filter); land the cut at the real first-kept message, not the raw index.
let cut = b.messages.iter().position(|m| m.at == boundary && m.role == Role::User).unwrap_or(n.min(b.messages.len()));
b.messages.splice(..cut, [summary]);
}
StreamEvent::ToolStatus(call_id, st) => {
if let Some(c) = b.messages.iter_mut().rev().flat_map(|m| m.tool_calls.iter_mut()).find(|c| c.id == call_id) {
@ -1203,7 +1262,7 @@ impl App {
other => {
let Some(m) = b.messages.last_mut() else { continue };
match other {
StreamEvent::Text(t) => m.text.push_str(&t),
StreamEvent::Text(t) => m.text.push_str(&crate::i18n::reply_text(&t)),
StreamEvent::Thinking(t) => m.thinking.push_str(&t),
StreamEvent::ToolCall(c) => m.tool_calls.push(c),
StreamEvent::StepDone(full) => {
@ -2214,7 +2273,7 @@ impl App {
crate::icons::label(ui, crate::icons::Icon::Check, &t("輔助使用已打開"), 11.5, p.text3);
} else if ui.small_button(t("打開輔助使用")).on_hover_text(t("macOS 要你允許一次,AnyBot 才看得到你的瀏覽器視窗")).clicked() {
if !crate::axread::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;
@ -3190,7 +3249,8 @@ impl App {
std::thread::sleep(std::time::Duration::from_millis(100));
}
let secs = t0.elapsed().as_secs();
let msgs = app.state.bot(id).map(|b| b.messages[start..].to_vec()).unwrap_or_default();
// The chat can shrink (compaction) between the snapshot and now: slice safely.
let msgs = app.state.bot(id).and_then(|b| b.messages.get(start..)).map(|m| m.to_vec()).unwrap_or_default();
let clip = |s: &str, n: usize| -> String {
let one = s.replace('\n', " ⏎ ");
if one.chars().count() > n { format!("{}…", one.chars().take(n).collect::<String>()) } else { one }
@ -3668,17 +3728,23 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
tools::Place::Local { workdir } => format!(
"- {}:{},起始資料夾 {}{}",
t.name,
if cfg!(target_os = "macos") { "使用者的這台 Mac" } else if crate::platform::has_desktop() { "使用者的這台 Linux 電腦(快捷鍵用 Ctrl,不是 ⌘)" } else { "AnyBot 所在的伺服器(不是使用者的 Mac;要用使用者的 Mac 請選帳號裡那台電腦的名字)" },
if t.name == crate::model::BRAIN_HOST_NAME { "你(大腦)所在的伺服器,不是使用者的電腦;只有使用者明確要在伺服器上做事才用" } else if cfg!(target_os = "macos") { "使用者的這台 Mac" } else if crate::platform::has_desktop() { "使用者的這台 Linux 電腦(快捷鍵用 Ctrl,不是 ⌘)" } else { "AnyBot 所在的伺服器(不是使用者的 Mac;要用使用者的 Mac 請選帳號裡那台電腦的名字)" },
workdir.display(),
if st.local_execution { "" } else { "(還沒預先允許:直接使用工具即可,App 會跳出視窗問使用者)" }
),
tools::Place::Ssh { target, .. } => format!("- {}:使用者的另一台電腦(SSH {target})", t.name),
tools::Place::Device { host } if t.name == crate::model::THIS_COMPUTER_NAME => format!(
"- {}:使用者自己的電腦「{}」(你在伺服器上,透過它的 AnyBot 操作它:檔案、指令、瀏覽器和 computer_use_* 工具,computer=「{}」)。使用者說「我的電腦」「我的筆電」「這台」或提到 Mac,都是指它。",
t.name,
crate::remote::peer_name(host),
t.name
),
tools::Place::Device { .. } => format!("- {}:使用者帳號裡的另一台電腦(裝置連結;可以用檔案、指令和 computer_use_* 工具,computer=「{}」)", t.name, t.name),
})
.collect();
s.push_str(&format!(
"\n\n你可以用工具在電腦上做事。每個工具都有 computer 參數,預設是「{default_computer}」。\n{}\n\
- 使用者說在哪台電腦上做(「在我的 Mac 上」「在工作站上」),就把 computer 設成那台;沒說就用預設。\n\
- 使用者替你選的電腦是「{default_computer}」:檔案、指令、看畫面、點擊和瀏覽器都在這台做,不要自己換到別台。只有使用者在訊息裡指名另一台(「在我的 Mac 上」「在 Bot 的電腦上」),才把 computer 設成那台。\n\
- 先用 list_dir、search、read_file 看清楚,再動手。\n\
- 需要權限時(電腦工具還沒打開、要用使用者的電腦、會改變東西的操作),直接呼叫工具,App 會自己跳出視窗請使用者允許;不要叫使用者自己去改設定。被拒絕就換個方式或問使用者。\n\
- 跑很久的指令會移到背景,用 read_terminal 追蹤,不要重複執行;它在等輸入時(例如問 y/n),用 write_terminal 回答。\n\

View File

@ -96,6 +96,18 @@ fn start_job(kind: &str, path: &str) -> Option<Arc<Mutex<PageJob>>> {
g.done = true;
});
}
"image" => {
// Decoding (a 6000×4000 photo, a big SVG) on the UI thread froze the window;
// the viewer shows a spinner until this thread is done.
std::thread::spawn(move || {
let img = preview::decode_image(&path);
let mut g = j.lock().unwrap();
if let Some(img) = img {
g.ready.push(img);
}
g.done = true;
});
}
"video" => {
std::thread::spawn(move || {
let out = std::env::temp_dir().join(format!("anybot-poster-{}-{}.png", std::process::id(), crate::model::now_ms()));
@ -258,9 +270,31 @@ impl App {
});
}
Preview::Image => {
// The picture is decoded on a worker thread (start_job "image").
if fv.image.is_none() {
if let Some(job) = fv.job.clone() {
let (imgs, done) = {
let mut g = job.lock().unwrap();
(std::mem::take(&mut g.ready), g.done)
};
match imgs.into_iter().next() {
Some(img) => fv.image = Some(texture(ui.ctx(), "file-view", &img)),
None => {
ui.add_space(40.0);
ui.vertical_centered(|ui| {
if done {
ui.label(RichText::new(t("這張圖片打不開")).color(p.text3));
} else {
ui.spinner();
ui.ctx().request_repaint_after(std::time::Duration::from_millis(80));
}
});
}
}
} else {
fv.image = load_texture(ui.ctx(), &fv.path);
}
}
match &fv.image {
Some(tex) => {
let avail = vec2(ui.available_width() - 24.0, body_h - 24.0);

View File

@ -777,6 +777,12 @@ impl App {
let dark = p.pane != LIGHT.pane;
let key = key_of(src, dark);
if !self.md.diagrams.contains_key(&key) {
// Keep a long chat from growing forever: every diagram is a GPU texture. Past a
// roomy cap, drop the whole cache — the diagrams shown again redraw on a worker
// thread (a brief spinner beats slowly eating hundreds of MBs).
if self.md.diagrams.len() >= 64 {
self.md.diagrams.clear();
}
let slot = Arc::new(Mutex::new(None));
let (s2, src2) = (slot.clone(), src.to_string());
let ctx = ui.ctx().clone();

View File

@ -262,14 +262,20 @@ impl App {
if self.state.bot(bot).is_none() {
return;
}
// Only files the app itself stored in its attachments folder may ride along;
// resolve first, so "../../" paths cannot escape it (the phone can name any path).
let root = crate::model::State::path().parent().map(|p| p.join("attachments")).and_then(|p| p.canonicalize().ok());
let atts: Vec<crate::attach::Attachment> = attachments
.iter()
.filter(|p| std::path::Path::new(p).starts_with(crate::model::State::path().parent().unwrap_or(std::path::Path::new("/"))))
.filter_map(|p| {
let meta = std::fs::metadata(p).ok()?;
let file = std::path::Path::new(p).file_name()?.to_string_lossy().to_string();
let f = std::path::Path::new(p).canonicalize().ok()?;
if !root.as_ref().map(|r| f.starts_with(r) && f.is_file()).unwrap_or(false) {
return None;
}
let meta = std::fs::metadata(&f).ok()?;
let file = f.file_name()?.to_string_lossy().to_string();
let name = file.split_once('-').map(|x| x.1.to_string()).unwrap_or(file);
Some(crate::attach::Attachment { image: crate::attach::is_image_name(&name), name, path: p.clone(), bytes: meta.len(), on_box: String::new() })
Some(crate::attach::Attachment { image: crate::attach::is_image_name(&name), name, path: f.display().to_string(), bytes: meta.len(), on_box: String::new() })
})
.collect();
if let Some(l) = self.streams.get(&bot) {
@ -439,6 +445,14 @@ impl App {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(egui::Button::new(RichText::new(t("登出帳號")).size(12.5).color(p.danger)).frame(false)).clicked() {
let keep_name = self.state.remote.host_name.clone();
// Say goodbye to the account server: the machine's registration is
// removed, so its key dies with the sign-out.
let (url, token) = (self.state.remote.relay_url.clone(), self.state.remote.host_token.clone());
if !url.is_empty() && !token.is_empty() {
std::thread::spawn(move || {
let _ = remote::relay_post(&format!("{url}/relay/host/remove"), &token, &serde_json::json!({}));
});
}
let s = &mut self.state.remote;
s.host_id.clear();
s.host_token.clear();
@ -720,12 +734,15 @@ impl App {
if self.state.remote.host_token.is_empty() {
return vec![];
}
let has_hands = !remote::my_hands(&self.state.remote.host_id).is_empty();
self.rm
.peers
.lock()
.unwrap()
.iter()
.filter(|(id, _, online)| !id.is_empty() && *online && *id != self.state.remote.host_id)
// A brain with hands of its own works on those, not on the account's other brains.
.filter(|(id, _, _)| !has_hands || remote::is_hands(id))
.map(|(id, name, _)| tools::Target { name: name.clone(), place: tools::Place::Device { host: id.clone() } })
.collect()
}

View File

@ -587,10 +587,13 @@ impl App {
VaultDialog::Import(mut st) => {
if self.vault_import(ctx, &mut st) {
next = Some(VaultDialog::Import(st));
}
} else {
// Only when it closed: marking saved every frame reloaded the whole
// vault list on every repaint while the import dialog was open.
saved = true;
}
}
}
self.vault.dialog = next;
if saved {
self.vault_refresh();

View File

@ -528,7 +528,7 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome
if changed.is_empty() {
return err("沒有要改的項目".into());
}
ok(format!("已更新「{}」的{}(現在:{} · {})", b.name, changed.join("、"), b.provider.label(), b.model))
ok(format!("已更新「{}」的{}(現在:{} · {},預設電腦「{}」)", b.name, changed.join("、"), b.provider.label(), b.model, b.default_computer))
}
"message_bot" => {
let Some(n) = s("bot") else { return err("要指定 Bot".into()) };
@ -545,7 +545,7 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome
"list_computers" => {
let mut lines = vec![
format!("{}:你自己的電腦(獨立的資料夾),一律可用", model::BOT_COMPUTER_NAME),
format!("{}:這台 Mac,{}", model::THIS_COMPUTER_NAME, if state.local_execution { "已允許" } else { "使用者還沒在設定裡允許(設定 → 電腦 → 在這台電腦上執行)" }),
format!("{}:{},{}", model::THIS_COMPUTER_NAME, if cfg!(target_os = "macos") { "這台 Mac" } else { "這台電腦" }, if state.local_execution { "已允許" } else { "使用者還沒在設定裡允許(設定 → 電腦 → 在這台電腦上執行)" }),
];
lines.extend(state.remote_computers.iter().map(|c| format!("{}:透過 SSH 連到 {}", c.name, c.ssh_target)));
ok(lines.join("\n"))
@ -554,13 +554,20 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome
let mut out = Outcome { text: String::new(), ok: true, wake: vec![], theme_changed: false, page: None };
let mut changed = vec![];
if let Some(t) = s("theme") {
state.theme = match t.as_str() {
"dark" => Theme::Dark,
"system" => Theme::System,
_ => Theme::Light,
// An unknown name used to mean Light with no word about it.
let (th, known) = match t.as_str() {
"dark" => (Theme::Dark, true),
"system" => (Theme::System, true),
"light" => (Theme::Light, true),
_ => (state.theme, false),
};
if known {
state.theme = th;
out.theme_changed = true;
changed.push(format!("主題 → {t}"));
} else {
out.text.push_str(&format!("主題「{t}」不存在(可用 dark、light、system)。\n"));
}
}
if let Some(a) = s("accent") {
state.accent = if a == "bot_color" { Accent::BotColor } else { Accent::Black };

View File

@ -304,7 +304,7 @@ pub fn read_person_browser(exe: &str, max_chars: usize) -> Result<Page, String>
return Err("只支援 macOS".into());
}
if !trusted(true) {
let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility").spawn();
let _ = crate::computer::spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"));
return Err("macOS 還沒讓 AnyBot 使用「輔助使用」,所以看不到你的瀏覽器。我已經打開「系統設定 → 隱私權與安全性 → 輔助使用」:把 AnyBot(或「終端機」)打開,然後再叫我試一次。只要開一次。".into());
}
let ps = std::process::Command::new("ps").args(["-axo", "pid=,args="]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default();

View File

@ -61,6 +61,9 @@ pub fn engine() -> Option<(&'static str, &'static str)> {
}
pub fn available() -> bool {
if using_box() {
return true;
}
engine().is_some()
}
@ -132,6 +135,9 @@ pub fn match_profile(list: &[String], want: &str) -> Option<String> {
/// Which profile a tool call means: the one named, or the current one.
pub fn resolve(requested: Option<&str>) -> Result<String, String> {
if using_box() {
return Ok(DEFAULT_PROFILE.to_string());
}
match requested.map(str::trim).filter(|s| !s.is_empty()) {
None => Ok(current()),
Some(r) => {
@ -231,8 +237,35 @@ pub fn encode_component(s: &str) -> String {
.collect()
}
thread_local! {
/// Browser steps on this thread go to Bot 的電腦's Chromium (its box), not this computer's.
static IN_BOX: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Run `f` with the browser steps on Bot 的電腦's own Chromium.
pub fn in_box<T>(f: impl FnOnce() -> T) -> T {
IN_BOX.with(|b| b.set(true));
let r = f();
IN_BOX.with(|b| b.set(false));
r
}
pub fn using_box() -> bool {
IN_BOX.with(|b| b.get())
}
/// Start this profile's window if it isn't running. Returns (browser name, port, just started).
pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u16, bool), String> {
if using_box() {
let fresh = !crate::cloudbox::browser_running();
let port = crate::cloudbox::browser_port()?;
if fresh {
if let Some(u) = first_url {
let _ = local().put(&endpoint(port, &format!("/json/new?{}", encode_component(u)))).send_empty();
}
}
return Ok(("Bot 的電腦的 Chromium", port, fresh && first_url.is_some()));
}
let Some((name, bin)) = engine() else { return Err("這台電腦沒有 Chrome、Brave、Edge 或 Chromium,沒辦法開 AnyBot 自己的瀏覽器。".into()) };
if profile == PERSON_PROFILE {
return Err("「你的瀏覽器」是你自己的視窗,AnyBot 不會另外開。".into());
@ -245,13 +278,20 @@ pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u
let _ = std::fs::remove_file(dir.join(ACTIVE_PORT_FILE));
// 「預設」 keeps its fixed port; other profiles let Chrome pick a free one and read it back.
let launch_port = if profile == DEFAULT_PROFILE { default_port() } else { 0 };
// Defense in depth: this becomes a Chrome launch argument. Every caller normalizes to a
// full URL first; a bare "-…" (a Chrome flag) must never get here from a tool argument.
let url = match first_url {
Some(u) if u.starts_with("http://") || u.starts_with("https://") => u,
Some(_) => return Err("第一個網址必須是 http(s) 網址".into()),
None => "about:blank",
};
let mut c = Command::new(bin);
c.arg(format!("--user-data-dir={}", dir.display()))
.arg(format!("--remote-debugging-port={launch_port}"))
.arg("--remote-allow-origins=http://127.0.0.1")
.arg("--no-first-run")
.arg("--no-default-browser-check")
.arg(first_url.unwrap_or("about:blank"))
.arg(url)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());

View File

@ -143,6 +143,12 @@ fn agent(timeout: u64) -> ureq::Agent {
ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(timeout))).http_status_as_error(false).build().into()
}
/// Seconds to wait after a 429 ("rate:N" from get_json), bounded so a bogus value can't park
/// the channel forever.
fn rate_secs(e: &str) -> u64 {
e.split(':').nth(1).and_then(|s| s.parse().ok()).unwrap_or(10).clamp(1, 300)
}
fn get_json(url: &str, auth: Option<&str>, timeout: u64) -> Result<Value, String> {
let mut r = agent(timeout).get(url);
if let Some(a) = auth {
@ -152,7 +158,10 @@ fn get_json(url: &str, auth: Option<&str>, timeout: u64) -> Result<Value, String
let status = resp.status().as_u16();
let text = resp.body_mut().read_to_string().unwrap_or_default();
if status == 429 {
return Err("rate".into());
// Carry the server's own "wait N seconds" (Telegram and Slack both send it); a fixed
// 5-second retry used to hammer the API and stay rate-limited.
let ra = serde_json::from_str::<Value>(&text).ok().and_then(|v| v["parameters"]["retry_after"].as_u64()).unwrap_or(10);
return Err(format!("rate:{ra}"));
}
if !(200..300).contains(&status) {
return Err(format!("HTTP {status}:{}", text.chars().take(160).collect::<String>()));
@ -457,8 +466,9 @@ fn poll(c: Channel, gen: u64, tx: Sender<Incoming>, repaint: Arc<dyn Fn() + Send
match get_json(&url, Some(&auth), 20) {
Ok(v) if v["ok"].as_bool() == Some(true) => {
if cursor.is_empty() {
// Start from now: older messages aren't answered.
cursor = v["messages"][0]["ts"].as_str().unwrap_or("0").to_string();
// Start from now: older messages aren't answered. (An empty channel
// has no ts to stand on — "0" made the next round replay history.)
cursor = v["messages"].get(0).and_then(|m| m["ts"].as_str()).map(String::from).unwrap_or_else(|| chrono::Utc::now().timestamp().to_string());
set_status(c.id, true, "已連上,等待新訊息", 0);
} else {
let (next, items) = slack_new(&v, &cursor, &own);
@ -469,7 +479,7 @@ fn poll(c: Channel, gen: u64, tx: Sender<Incoming>, repaint: Arc<dyn Fn() + Send
}
}
Ok(v) => set_status(c.id, false, &format!("Slack 說:{}", v["error"].as_str().unwrap_or("不明錯誤")), 0),
Err(e) if e == "rate" => std::thread::sleep(Duration::from_secs(20)),
Err(e) if e.starts_with("rate") => std::thread::sleep(Duration::from_secs(rate_secs(&e))),
Err(e) => set_status(c.id, false, &e, 0),
}
std::thread::sleep(Duration::from_secs(POLL_SECS));
@ -497,7 +507,7 @@ fn poll(c: Channel, gen: u64, tx: Sender<Incoming>, repaint: Arc<dyn Fn() + Send
}
}
}
Err(e) if e == "rate" => std::thread::sleep(Duration::from_secs(10)),
Err(e) if e.starts_with("rate") => std::thread::sleep(Duration::from_secs(rate_secs(&e))),
Err(e) => set_status(c.id, false, &e, 0),
}
std::thread::sleep(Duration::from_secs(POLL_SECS));
@ -530,7 +540,7 @@ fn poll(c: Channel, gen: u64, tx: Sender<Incoming>, repaint: Arc<dyn Fn() + Send
}
}
}
Err(e) if e == "rate" => std::thread::sleep(Duration::from_secs(20)),
Err(e) if e.starts_with("rate") => std::thread::sleep(Duration::from_secs(rate_secs(&e))),
Err(e) => set_status(c.id, false, &e, 0),
}
}
@ -655,12 +665,20 @@ fn poll(c: Channel, gen: u64, tx: Sender<Incoming>, repaint: Arc<dyn Fn() + Send
}
}
Kind::Telegram => {
// Skip what's waiting from before: start after the latest update.
let mut offset = get_json(&format!("{}/bot{token}/getUpdates?offset=-1", tg()), None, 15)
.ok()
.and_then(|v| v["result"][0]["update_id"].as_i64())
.map(|u| u + 1)
.unwrap_or(0);
// Skip what's waiting from before: start after the latest update. If this acknowledge
// fails, offset 0 would replay the whole backlog on the first poll — retry it.
let mut offset = loop {
match get_json(&format!("{}/bot{token}/getUpdates?offset=-1", tg()), None, 15) {
Ok(v) => break v["result"].as_array().and_then(|a| a.last()).and_then(|u| u["update_id"].as_i64()).map(|u| u + 1).unwrap_or(1),
Err(e) => {
set_status(c.id, false, &format!("正在連上 Telegram:{e}"), 0);
if !alive(gen) {
return;
}
std::thread::sleep(Duration::from_secs(rate_secs(&e)));
}
}
};
set_status(c.id, true, "已連上,等待新訊息", 0);
while alive(gen) {
let url = format!("{}/bot{token}/getUpdates?offset={offset}&timeout={TELEGRAM_WAIT_SECS}", tg());
@ -677,7 +695,8 @@ fn poll(c: Channel, gen: u64, tx: Sender<Incoming>, repaint: Arc<dyn Fn() + Send
}
Err(e) => {
set_status(c.id, false, &e, 0);
std::thread::sleep(Duration::from_secs(5));
// A 429 says how long to back off; hammering every 5 s keeps it limited.
std::thread::sleep(Duration::from_secs(if e.starts_with("rate") { rate_secs(&e) } else { 5 }));
}
}
}

View File

@ -129,6 +129,10 @@ pub struct Config {
/// 網路流量改走你的電腦 (new connections only).
#[serde(default)]
pub egress_via_mac: bool,
/// VNC password for the desktop viewer (generated on first use; the published port is
/// loopback-only, but the box's own programs can reach it, and they should need a key).
#[serde(default)]
pub vnc_password: String,
}
/// A scheduled update that was missed may still run for this long (the original: one hour).
@ -155,6 +159,22 @@ fn config() -> &'static Mutex<Config> {
C.get_or_init(|| Mutex::new(load_config()))
}
/// The box's VNC password: made once, kept across restarts (the container is started with it,
/// the viewer opens with it). x11vnc ran with no password at all before — any program inside
/// the box (a page the bot opened, for instance) could watch and drive the desktop.
pub fn vnc_password() -> String {
{
let c = config().lock().unwrap();
if !c.vnc_password.is_empty() {
return c.vnc_password.clone();
}
}
let pw = crate::httpd::random_hex(10);
config().lock().unwrap().vnc_password.clone_from(&pw);
save_config(&config().lock().unwrap());
pw
}
static BUSY: AtomicUsize = AtomicUsize::new(0);
/// The person took control in the viewer: the bot's screen actions wait.
pub static HELD_BY_PERSON: AtomicBool = AtomicBool::new(false);
@ -378,12 +398,10 @@ fn docker_prefix() -> String {
/// Run docker; Ok(stdout) or Err(stderr).
fn dk(args: &[&str]) -> Result<String, String> {
let out = docker().args(args).output().map_err(|e| format!("找不到 docker:{e}"))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
// Never block forever on the daemon: a hung docker daemon used to pile up a new stuck
// thread every 20 s from the refresh loop. (docker's own CLI has no default timeout.)
let out = dk_timeout(args, 120_000)?;
Ok(String::from_utf8_lossy(&out).trim().to_string())
}
fn dk_timeout(args: &[&str], ms: u64) -> Result<Vec<u8>, String> {
@ -493,7 +511,8 @@ fn build_image(pull: bool) -> Result<(), String> {
}
let d = dir.display().to_string();
args.push(&d);
let r = dk(&args);
// Builds take minutes (a cold one more than dk's short timeout allows).
let r = dk_timeout(&args, 20 * 60_000).map(|_| String::new());
let _ = std::fs::remove_dir_all(&dir);
r.map(|_| log("映像檔完成。")).map_err(|e| format!("建立映像檔失敗:{}", tail(&e, 600)))
}
@ -524,9 +543,10 @@ fn create_container() -> Result<(), String> {
let publish = format!("127.0.0.1:{port}:{NOVNC_PORT}");
let vol = format!("{VOLUME}:{HOME}");
let env = format!("SCREEN_SIZE={SCREEN}");
let vnc = format!("VNC_PASSWORD={}", vnc_password());
log("正在開一台新的電腦…");
// host.docker.internal → this Mac (for 網路流量改走你的電腦; Docker Desktop and OrbStack already have it).
dk(&["run", "-d", "--name", CONTAINER, "--hostname", "bot-computer", "--shm-size", "1g", "--add-host", "host.docker.internal:host-gateway", "-p", &publish, "-v", &vol, "-e", &env, "--label", "anybot.box=1", IMAGE]).map_err(|e| format!("開不了電腦:{}", tail(&e, 400)))?;
dk(&["run", "-d", "--name", CONTAINER, "--hostname", "bot-computer", "--shm-size", "1g", "--pids-limit", "4096", "--memory", "2g", "--cpus", "2", "--add-host", "host.docker.internal:host-gateway", "-p", &publish, "-v", &vol, "-e", &env, "-e", &vnc, "--label", "anybot.box=1", IMAGE]).map_err(|e| format!("開不了電腦:{}", tail(&e, 400)))?;
Ok(())
}
@ -541,6 +561,73 @@ fn wait_ready(ms: u64) -> Result<(), String> {
Err("電腦開機逾時".into())
}
// ---------- the box's own browser ----------
// Browser steps on Bot 的電腦 drive the Chromium on its desktop (the person can watch it in the
// box's window). Its DevTools port stays inside the box; a local port reaches it through
// `docker exec` (no published port, so it also works with a Docker host elsewhere).
const BOX_CDP_PORT: u16 = 9222;
fn box_cdp_alive(local: u16) -> bool {
crate::botbrowser::local().get(&format!("http://127.0.0.1:{local}/json/version")).call().map(|r| r.status().as_u16() == 200).unwrap_or(false)
}
/// A local port that reaches the box's DevTools port.
fn box_cdp_forward() -> Result<u16, String> {
static PORT: Mutex<u16> = Mutex::new(0);
let mut p = PORT.lock().unwrap();
if *p != 0 {
return Ok(*p);
}
let l = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
let port = l.local_addr().map_err(|e| e.to_string())?.port();
std::thread::spawn(move || {
for conn in l.incoming().flatten() {
std::thread::spawn(move || {
let script = format!("exec 3<>/dev/tcp/127.0.0.1/{BOX_CDP_PORT} || exit 1; cat <&3 & cat >&3; kill $! 2>/dev/null");
let Ok(mut child) = docker().args(["exec", "-i", CONTAINER, "bash", "-c", &script]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { return };
let (Some(mut cin), Some(mut cout)) = (child.stdin.take(), child.stdout.take()) else { return };
let (Ok(mut r), Ok(mut w)) = (conn.try_clone(), conn.try_clone()) else { return };
let up = std::thread::spawn(move || {
let _ = std::io::copy(&mut r, &mut cin);
});
let _ = std::io::copy(&mut cout, &mut w);
let _ = conn.shutdown(std::net::Shutdown::Both);
let _ = child.kill();
let _ = up.join();
let _ = child.wait();
});
}
});
*p = port;
Ok(port)
}
/// Is the box's Chromium answering already?
pub fn browser_running() -> bool {
box_cdp_forward().map(box_cdp_alive).unwrap_or(false)
}
/// The box's Chromium, started on its desktop when it isn't running. Gives the local port.
pub fn browser_port() -> Result<u16, String> {
ensure_running()?;
touch();
let port = box_cdp_forward()?;
if box_cdp_alive(port) {
return Ok(port);
}
let dbg = format!("--remote-debugging-port={BOX_CDP_PORT}");
dk(&["exec", "-d", "-u", "bot", "-e", "DISPLAY=:1", "-e", "HOME=/home/bot", CONTAINER, "chromium", &dbg, "--remote-allow-origins=*", "--user-data-dir=/home/bot/.config/anybot-browser", "about:blank"]).map_err(|e| format!("Bot 的電腦打不開瀏覽器:{e}"))?;
let start = Instant::now();
while start.elapsed() < Duration::from_secs(20) {
if box_cdp_alive(port) {
return Ok(port);
}
std::thread::sleep(Duration::from_millis(300));
}
Err("Bot 的電腦的瀏覽器沒有及時打開,請再試一次。".into())
}
static START_LOCK: Mutex<()> = Mutex::new(());
/// Make sure the box is up, waking or creating it as needed. Blocks until it's ready.
@ -657,6 +744,15 @@ pub fn save_snapshot() -> Result<u64, String> {
.stderr(Stdio::piped())
.spawn()
.map_err(|e| e.to_string())?;
// Drain stderr: tar's file-changed warnings pile up fast, and a full 64 KB pipe stalls
// tar (and the snapshot) until the kill timer fires.
let mut se = child.stderr.take().ok_or("沒有輸出")?;
let err_t = std::thread::spawn(move || {
use std::io::Read;
let mut v = vec![];
let _ = se.read_to_end(&mut v);
v
});
let start = Instant::now();
let ok = loop {
if let Ok(Some(st)) = child.try_wait() {
@ -668,6 +764,7 @@ pub fn save_snapshot() -> Result<u64, String> {
}
std::thread::sleep(Duration::from_millis(50));
};
let _ = err_t.join();
if !ok {
let _ = std::fs::remove_file(&tmp);
return Err("快照沒有存成功".into());
@ -929,6 +1026,11 @@ pub fn set_secret(name: &str, value: &str) -> Result<(), String> {
if value.is_empty() {
return Err("請填入值".into());
}
// docker's --env-file keeps values on one line; a value with newlines used to be written
// with literal "\n" escapes and arrived broken (multi-line PEM keys) — say so up front.
if value.contains(['\n', '\r']) {
return Err("值裡不能有換行(多行金鑰請壓成一行,例如 -----BEGIN KEY-----…-----END KEY-----)".into());
}
if value.len() > SECRET_MAX_VALUE_BYTES {
return Err(format!("值太長了(最多 {} KB)", SECRET_MAX_VALUE_BYTES / 1024));
}
@ -955,22 +1057,49 @@ pub fn bot_dir(bot_id: u64) -> String {
}
/// A local shell string that runs `cmd` in the box as the bot user, in `dir`, with the secrets.
pub fn exec_wrapper(dir: &str, cmd: &str, interactive: bool) -> String {
pub fn exec_wrapper(dir: &str, cmd: &str, interactive: bool, job: &str) -> String {
use crate::tools::sh_quote;
let inner = format!("mkdir -p {d} && cd {d} && {cmd}", d = sh_quote(dir));
// The job tag rides in the command line so the app can kill the in-container process later
// (killing the local docker client alone leaves the program running in the box).
let tagged = if job.is_empty() { cmd.to_string() } else { format!("export ANYBOT_JOB={job}; {cmd}") };
let inner = format!("mkdir -p {d} && cd {d} && {tagged}", d = sh_quote(dir));
let env = env_file_path();
let env_arg = if env.exists() { format!(" --env-file {}", sh_quote(&env.display().to_string())) } else { String::new() };
format!("{} exec{} -u bot -e DISPLAY={DISPLAY} -e HOME={HOME}{}{env_arg} {CONTAINER} bash -lc {}", docker_prefix(), if interactive { " -i" } else { "" }, egress_env(), sh_quote(&inner))
}
/// An argv that kills the box process carrying this job tag (and its children).
pub fn exec_kill_argv(job: &str) -> Vec<String> {
let mut argv = vec!["docker".to_string()];
let host = docker_host();
if !host.is_empty() {
argv.extend(["-H".to_string(), host]);
}
argv.extend([
"exec".to_string(),
CONTAINER.to_string(),
"sh".to_string(),
"-c".to_string(),
format!("for p in $(pgrep -f 'export ANYBOT_JOB={job}'); do pkill -9 -P $p; pkill -9 -P $p; kill -9 $p 2>/dev/null; done; true"),
]);
argv
}
/// Put a file into a bot's folder on the box (only when the box is already running).
pub fn copy_in(local: &str, bot_id: u64, name: &str) -> Result<String, String> {
if !run_state().is_running() {
return Err("Bot 的電腦沒有在執行".into());
}
// The name arrives from uploads: keep only its file part, so no `../` can steer the
// root-run `docker cp` out of this bot's attachments folder.
let safe = std::path::Path::new(name)
.file_name()
.map(|n| n.to_string_lossy().chars().filter(|c| *c != '\0').collect::<String>())
.filter(|n| !n.is_empty() && n != "." && n != "..")
.ok_or_else(|| "檔案名稱無效".to_string())?;
let dir = format!("{}/附件", bot_dir(bot_id));
dk(&["exec", "-u", "bot", CONTAINER, "mkdir", "-p", &dir])?;
let dst = format!("{dir}/{name}");
let dst = format!("{dir}/{safe}");
dk(&["cp", local, &format!("{CONTAINER}:{dst}")])?;
let _ = dk(&["exec", CONTAINER, "chown", "bot:bot", &dst]);
Ok(dst)
@ -1107,11 +1236,20 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option<String>), S
Ok((format!("{}", (x * s.0).round() as i64), format!("{}", (y * s.1).round() as i64)))
};
let with_mods = |mods: &[String], f: &dyn Fn() -> Result<String, String>| -> Result<String, String> {
// Track what went down: a failed keydown must release the keys already held, or a
// stuck modifier poisons every later click and keystroke in the box.
let mut pressed: Vec<&String> = vec![];
for m in mods {
xdo(&["keydown", m])?;
if let Err(e) = xdo(&["keydown", m]) {
for p in pressed.iter().rev() {
let _ = xdo(&["keyup", p]);
}
return Err(e);
}
pressed.push(m);
}
let r = f();
for m in mods.iter().rev() {
for m in pressed.iter().rev() {
let _ = xdo(&["keyup", m]);
}
r
@ -1170,7 +1308,9 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option<String>), S
}
"computer_use_drag" => {
let s = scale()?;
let path: Vec<(String, String)> = v["path"].as_array().map(|a| a.iter().filter_map(|p| pt(p, s).ok()).collect()).unwrap_or_default();
// A point that fails to parse is an error, not a silently dropped waypoint.
let raw = v["path"].as_array().ok_or("拖曳要給 path:一個點陣,每個點有數字 x 和 y(用截圖上的座標)")?;
let path: Vec<(String, String)> = raw.iter().map(|p| pt(p, s)).collect::<Result<_, _>>()?;
if path.len() < 2 {
return Err("拖曳至少要兩個點(每個點要有數字 x 和 y,用截圖上的座標)".into());
}
@ -1307,7 +1447,8 @@ pub fn viewer_url(control: bool) -> Result<String, String> {
} else {
port
};
Ok(format!("http://127.0.0.1:{local_port}/vnc.html?autoconnect=1&resize=scale&reconnect=1&show_dot=1{}", if control { "" } else { "&view_only=1" }))
// noVNC signs in with the box's VNC password (x11vnc refuses the keyless login now).
Ok(format!("http://127.0.0.1:{local_port}/vnc.html?autoconnect=1&resize=scale&reconnect=1&show_dot=1&password={}{}", vnc_password(), if control { "" } else { "&view_only=1" }))
}
pub fn shutdown() {
@ -1457,7 +1598,7 @@ mod tests {
#[test]
fn wrapper_runs_as_the_bot_in_its_folder() {
let w = exec_wrapper("/home/bot/bot-3", "ls -la", false);
let w = exec_wrapper("/home/bot/bot-3", "ls -la", false, "");
assert!(w.contains("exec -u bot"));
assert!(w.contains(CONTAINER));
assert!(w.contains("/home/bot/bot-3"));

View File

@ -18,6 +18,16 @@ pub const NAMES: [&str; 12] = ["computer_use_screenshot", "computer_use_click",
pub static HELD_BY_PERSON: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// When the bot last moved the mouse or typed on this Mac (for the floating bar), and who.
pub static LAST_ACTION: Mutex<Option<(std::time::Instant, String)>> = Mutex::new(None);
/// Spawn a short-lived helper (`open`, a player, …) and reap it in the background:
/// spawn-without-wait left one zombie per call for the app's whole life.
pub fn spawn_reaped(c: &mut std::process::Command) -> std::io::Result<()> {
let mut child = c.spawn()?;
std::thread::spawn(move || {
let _ = child.wait();
});
Ok(())
}
/// Longest screenshot edge sent to the model (Gct).
pub const MAX_LONG_EDGE: u32 = 1024;
/// Shortest edge never goes below this (Hs).
@ -367,7 +377,7 @@ pub fn run(name: &str, args: &str) -> (String, bool, Option<String>) {
/// Same, remembering which bot acted (for the 「正在操作你的電腦」 bar).
pub fn run_by(name: &str, args: &str, who: &str) -> (String, bool, Option<String>) {
if name != "computer_use_screenshot" && name != "computer_use_check_permissions" {
*LAST_ACTION.lock().unwrap() = Some((std::time::Instant::now(), who.to_string()));
*LAST_ACTION.lock().unwrap_or_else(|e| e.into_inner()) = Some((std::time::Instant::now(), who.to_string()));
}
let r = run_inner(name, &args_of(args));
match r {
@ -456,10 +466,10 @@ fn run_inner(name: &str, v: &Value) -> Result<(String, Option<String>), String>
return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作;可以先截圖看看。".into());
}
if cfg!(target_os = "macos") && !crate::axread::trusted(true) {
let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility").spawn();
let _ = spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"));
return Err("macOS 還沒讓 AnyBot 使用「輔助使用」,所以不能動滑鼠和鍵盤。我已經打開「系統設定 → 隱私權與安全性 → 輔助使用」:把 AnyBot(或「終端機」)打開,再叫我試一次。只要開一次。".into());
}
let scale = || SCALE.lock().unwrap().ok_or_else(|| "請先用 computer_use_screenshot 看一下畫面,座標要用截圖上的位置。".to_string());
let scale = || SCALE.lock().unwrap_or_else(|e| e.into_inner()).ok_or_else(|| "請先用 computer_use_screenshot 看一下畫面,座標要用截圖上的位置。".to_string());
let pt = |v: &Value, s: (f64, f64)| -> Result<(f64, f64), String> {
let (Some(x), Some(y)) = (v["x"].as_f64(), v["y"].as_f64()) else { return Err("請給 x 和 y".into()) };
Ok(to_screen(x, y, s))
@ -500,7 +510,10 @@ fn run_inner(name: &str, v: &Value) -> Result<(String, Option<String>), String>
}
"computer_use_drag" => {
let s = scale()?;
let path: Vec<(f64, f64)> = v["path"].as_array().map(|a| a.iter().filter_map(|p| pt(p, s).ok()).collect()).unwrap_or_default();
// Every point must be valid: points that failed to parse used to be dropped in
// silence, so the drag ran on fewer points and reported success at the wrong place.
let raw = v["path"].as_array().ok_or("拖曳要給 path:一個點陣,每個點有數字 x 和 y(用截圖上的座標)")?;
let path: Vec<(f64, f64)> = raw.iter().map(|p| pt(p, s)).collect::<Result<_, _>>()?;
if path.len() < 2 {
return Err("拖曳至少要兩個點(每個點要有數字 x 和 y,用截圖上的座標)".into());
}
@ -559,7 +572,7 @@ pub fn target_app(name: &str, args: &str) -> Option<String> {
if run_text_mode_name(name, &v).is_some() {
return crate::desktop::last_app();
}
let s = (*SCALE.lock().unwrap())?;
let s = (*SCALE.lock().unwrap_or_else(|e| e.into_inner()))?;
let point = match name {
"computer_use_click" | "computer_use_mouse_move" | "computer_use_scroll" | "computer_use_mouse_button" if !v["x"].is_null() => Some(to_screen(v["x"].as_f64()?, v["y"].as_f64()?, s)),
"computer_use_drag" => v["path"].as_array().and_then(|a| a.first()).and_then(|p| Some(to_screen(p["x"].as_f64()?, p["y"].as_f64()?, s))),
@ -620,7 +633,7 @@ pub fn app_at_point(x: f64, y: f64) -> Option<String> {
fn screenshot() -> Result<(String, String), String> {
if !mac::screen_capture_allowed() {
let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture").spawn();
let _ = spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"));
return Err("macOS 還沒讓 AnyBot 錄製螢幕,所以看不到畫面。我已經打開「系統設定 → 隱私權與安全性 → 螢幕與系統錄音」:把 AnyBot(或「終端機」)打開,再叫我試一次。只要開一次。".into());
}
let img = capture_screen()?;
@ -630,7 +643,7 @@ fn screenshot() -> Result<(String, String), String> {
let jpeg = encode_jpeg(&img).ok_or("截圖太大了,沒辦法送出")?;
let (pw, ph) = mac::screen_points();
let scale = (if fw > 0 { pw / fw as f64 } else { 1.0 }, if fh > 0 { ph / fh as f64 } else { 1.0 });
*SCALE.lock().unwrap() = Some(scale);
*SCALE.lock().unwrap_or_else(|e| e.into_inner()) = Some(scale);
let b64 = base64::engine::general_purpose::STANDARD.encode(&jpeg);
Ok((format!("你的螢幕截圖({fw}×{fh})。之後的座標請用這張圖上的像素位置。"), b64))
}
@ -641,7 +654,9 @@ pub fn capture_screen() -> Result<image::RgbImage, String> {
return crate::desk::capture();
#[allow(unreachable_code)]
{
let file = std::env::temp_dir().join(format!("anybot-screen-{}-{}.png", std::process::id(), crate::model::now_ms()));
// A random tail: two bots screenshotting the same millisecond must not delete each
// other's file (pid+time alone collides).
let file = std::env::temp_dir().join(format!("anybot-screen-{}-{}-{}.png", std::process::id(), crate::model::now_ms(), crate::httpd::random_hex(4)));
let st = std::process::Command::new("screencapture").args(["-x", "-m", "-t", "png"]).arg(&file).status().map_err(|e| e.to_string())?;
let bytes = std::fs::read(&file);
let _ = std::fs::remove_file(&file);

View File

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

@ -43,11 +43,12 @@ pub fn start() -> Result<u16, String> {
Ok(port)
}
/// Who may use the proxy: this machine, and Docker's own networks (172.16.0.0/12, where the
/// bridge and compose networks live) — never the rest of the local network.
/// Who may use the proxy: this machine, and Docker's default bridge (172.17.0.0/16, where the
/// boxes run). The rest of 172.16.0.0/12 is often the corporate LAN — an open proxy there is
/// an open door, so it is not served.
pub fn allowed_peer(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v) => v.is_loopback() || (v.octets()[0] == 172 && (16..=31).contains(&v.octets()[1])),
std::net::IpAddr::V4(v) => v.is_loopback() || (v.octets()[0] == 172 && v.octets()[1] == 17),
std::net::IpAddr::V6(v) => v.is_loopback(),
}
}
@ -122,15 +123,27 @@ fn handle(mut client: TcpStream) -> Result<(), String> {
return Err(e);
}
};
let mut server = match TcpStream::connect((r.host.as_str(), r.port)) {
Ok(s) => s,
Err(e) => {
// Resolve and connect with a real timeout: a plain TcpStream::connect can hang ~2 minutes
// on a dead address, and every queued box request piles up behind it.
let addrs: Vec<std::net::SocketAddr> = match std::net::ToSocketAddrs::to_socket_addrs(&(r.host.as_str(), r.port)) {
Ok(a) => a.collect(),
Err(_) => {
let _ = client.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n");
return Err(e.to_string());
return Err(format!("連不到 {}", r.host));
}
};
let mut server = match addrs.into_iter().find_map(|a| TcpStream::connect_timeout(&a, std::time::Duration::from_secs(10)).ok()) {
Some(s) => s,
None => {
let _ = client.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n");
return Err(format!("連不到 {}", r.host));
}
};
CONNECTIONS.fetch_add(1, Ordering::Relaxed);
client.set_read_timeout(None).ok();
// A dead peer must not pin the copy loops (and their threads) forever: an idle tunnel ends.
let idle = std::time::Duration::from_secs(30 * 60);
client.set_read_timeout(Some(idle)).ok();
server.set_read_timeout(Some(idle)).ok();
if r.tunnel {
client.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n").map_err(|e| e.to_string())?;
} else {
@ -189,8 +202,12 @@ mod tests {
#[test]
fn only_this_machine_and_containers() {
assert!(super::allowed_peer("127.0.0.1".parse().unwrap()));
// Docker's default bridge (where the boxes run) is served…
assert!(super::allowed_peer("172.17.0.2".parse().unwrap()));
assert!(super::allowed_peer("172.31.255.1".parse().unwrap()));
// …but the rest of 172.16/12 (often the corporate LAN) is not: an open proxy there
// is an open door.
assert!(!super::allowed_peer("172.16.0.1".parse().unwrap()));
assert!(!super::allowed_peer("172.31.255.1".parse().unwrap()));
assert!(!super::allowed_peer("172.32.0.1".parse().unwrap()));
assert!(!super::allowed_peer("192.168.1.20".parse().unwrap()));
}

View File

@ -90,24 +90,35 @@ fn reason(status: u16) -> &'static str {
}
fn decode(s: &str) -> String {
// Work on bytes only: slicing the &str at byte offsets can land inside a multi-byte
// character and panic (an unauthenticated request like "/%)…" used to kill its thread).
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let hex = |c: u8| -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
};
let mut i = 0;
while i < b.len() {
match b[i] {
b'+' => out.push(b' '),
b'%' if i + 2 < b.len() => {
if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
out.push(v);
i += 2;
} else {
out.push(b'%');
}
}
c => out.push(c),
}
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < b.len() && hex(b[i + 1]).is_some() && hex(b[i + 2]).is_some() => {
out.push(hex(b[i + 1]).unwrap() * 16 + hex(b[i + 2]).unwrap());
i += 3;
}
c => {
out.push(c);
i += 1;
}
}
}
String::from_utf8_lossy(&out).to_string()
}
@ -143,11 +154,52 @@ pub fn read_request(c: &mut TcpStream) -> Result<Req, u16> {
let method = fp.next().unwrap_or("").to_string();
let target = fp.next().unwrap_or("/").to_string();
let headers: HashMap<String, String> = lines.filter_map(|l| l.split_once(':')).map(|(k, v)| (k.trim().to_ascii_lowercase(), v.trim().to_string())).collect();
let chunked = headers.get("transfer-encoding").map(|v| v.to_ascii_lowercase().contains("chunked")).unwrap_or(false);
let mut body = got[head_end + 4..].to_vec();
if chunked {
// Some phone browsers send POST bodies in chunks (no Content-Length): read chunks
// until the zero-size terminator, reassembling the body.
let mut out: Vec<u8> = vec![];
let mut pos = 0usize;
loop {
let size_end = loop {
if let Some(p) = body[pos..].windows(2).position(|w| w == b"\r\n") {
break pos + p;
}
if body.len() > MAX_BODY + 64 * 1024 {
return Err(413);
}
let n = c.read(&mut buf).map_err(|_| 400u16)?;
if n == 0 {
return Err(400);
}
body.extend_from_slice(&buf[..n]);
};
let hex = String::from_utf8_lossy(&body[pos..size_end]).split(';').next().unwrap_or("0").trim().to_string();
let size = usize::from_str_radix(&hex, 16).map_err(|_| 400u16)?;
if size == 0 {
break;
}
if out.len() + size > MAX_BODY {
return Err(413);
}
let end = size_end + 2 + size;
while body.len() < end + 2 {
let n = c.read(&mut buf).map_err(|_| 400u16)?;
if n == 0 {
return Err(400);
}
body.extend_from_slice(&buf[..n]);
}
out.extend_from_slice(&body[size_end + 2..end]);
pos = end + 2;
}
body = out;
} else {
let len: usize = headers.get("content-length").and_then(|v| v.parse().ok()).unwrap_or(0);
if len > MAX_BODY {
return Err(413);
}
let mut body = got[head_end + 4..].to_vec();
while body.len() < len {
let n = c.read(&mut buf).map_err(|_| 400u16)?;
if n == 0 {
@ -156,6 +208,7 @@ pub fn read_request(c: &mut TcpStream) -> Result<Req, u16> {
body.extend_from_slice(&buf[..n]);
}
body.truncate(len);
}
let (path, q) = target.split_once('?').unwrap_or((&target, ""));
Ok(Req { method, path: decode(path), query: parse_query(q), headers, body, peer: c.peer_addr().map(|a| a.ip().to_string()).unwrap_or_default() })
}

View File

@ -55,8 +55,22 @@ fn current() -> &'static RwLock<String> {
L.get_or_init(|| RwLock::new("zh-TW".into()))
}
/// Replies are wanted in Traditional Chinese (the language setting says so, not "auto").
static REPLIES_HANT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// What the bot wrote, in Traditional Chinese with Taiwan wording when that is the setting.
/// Models (Qwen especially) slip into Simplified characters despite the instruction.
pub fn reply_text(s: &str) -> String {
if REPLIES_HANT.load(std::sync::atomic::Ordering::Relaxed) && !s.is_empty() {
zhconv::zhconv(s, zhconv::Variant::ZhTW)
} else {
s.to_string()
}
}
/// "auto" follows the system language.
pub fn set(tag: &str) {
REPLIES_HANT.store(tag == "zh-TW", std::sync::atomic::Ordering::Relaxed);
let t = if tag == "auto" || tag.is_empty() { system() } else { tag.to_string() };
*current().write().unwrap() = t;
}
@ -208,6 +222,8 @@ mod tests {
fn languages_and_tables() {
assert_eq!(LANGS.len(), 37);
assert_eq!(match_tag("zh-Hant-TW"), "zh-TW");
set("zh-TW");
assert_eq!(reply_text("那边的软件"), "那邊的軟體");
assert_eq!(match_tag("zh-Hans-CN"), "zh-CN");
assert_eq!(match_tag("ja-JP"), "ja");
assert_eq!(match_tag("pt-PT"), "pt-PT");

View File

@ -250,13 +250,19 @@ fn rasterize(icon: Icon, px: u32) -> Option<egui::ColorImage> {
pub fn paint(p: &Painter, rect: Rect, icon: Icon, col: Color32) {
let ppp = p.ctx().pixels_per_point();
let side = rect.width().min(rect.height());
let px = (side * ppp).round().max(8.0) as u32;
// Quantised size (2 px steps, capped): a resized window used to create a new texture for
// every pixel width, so the cache grew without limit while the window was dragged.
let px = ((side * ppp).round().max(8.0) as u32 / 2 * 2).clamp(8, 512);
let tex = {
let mut c = cache().lock().unwrap();
match c.get(&(icon, px)) {
Some(t) => t.clone(),
None => {
let Some(img) = rasterize(icon, px) else { return };
// Roomy hard cap: past it, start the cache fresh (textures reload on demand).
if c.len() >= 1500 {
c.clear();
}
let t = p.ctx().load_texture(format!("icon-{icon:?}-{px}"), img, egui::TextureOptions::LINEAR);
c.insert((icon, px), t.clone());
t

View File

@ -185,7 +185,7 @@ fn jxa(script: &str, args: &Value) -> Result<String, String> {
fn permission_hint(app: &str, err: &str) -> String {
let e = err.to_lowercase();
if e.contains("-1743") || e.contains("not allowed") || e.contains("不允許") || e.contains("not authorized") {
let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Automation").spawn();
let _ = crate::computer::spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Automation"));
return format!("AnyBot 還不能控制這台 Mac 的「{app}」。我已經打開「系統設定 → 隱私權與安全性 → 自動化」:在 AnyBot(或「終端機」)底下打開「{app}」,再叫我試一次。");
}
format!("「{app}」回報錯誤:{}", err.lines().last().unwrap_or(err))

View File

@ -9,7 +9,7 @@ use std::collections::{BTreeMap, HashMap};
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::mpsc::{channel, Receiver};
use std::sync::{Mutex, OnceLock};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
pub const PROTOCOL: &str = "2025-06-18";
@ -58,15 +58,22 @@ enum Link {
Http { url: String, headers: BTreeMap<String, String>, session: Option<String> },
}
struct Conn {
/// The part a tool call mutates; locked per server, so one slow call (up to two minutes)
/// never blocks other servers, the UI's status line, or the app's own sync.
struct Live {
link: Link,
next_id: u64,
}
struct Conn {
live: Mutex<Live>,
tools: Vec<ToolInfo>,
config: Server,
}
fn conns() -> &'static Mutex<HashMap<String, Conn>> {
static C: OnceLock<Mutex<HashMap<String, Conn>>> = OnceLock::new();
/// One lock for the table, one lock per server's link.
fn conns() -> &'static Mutex<HashMap<String, Arc<Conn>>> {
static C: OnceLock<Mutex<HashMap<String, Arc<Conn>>>> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
}
@ -141,7 +148,7 @@ pub fn parse_http_answer(body: &str, id: u64) -> Result<Value, String> {
Err("MCP 伺服器沒有給答案".into())
}
impl Conn {
impl Live {
fn request(&mut self, method: &str, params: Value) -> Result<Value, String> {
let id = self.next_id;
self.next_id += 1;
@ -224,13 +231,13 @@ fn connect(s: &Server) -> Result<Conn, String> {
});
Link::Stdio { child, stdin, rx }
};
let mut c = Conn { link, next_id: 1, tools: vec![], config: s.clone() };
c.request("initialize", json!({"protocolVersion": PROTOCOL, "capabilities": {}, "clientInfo": {"name": "AnyBot", "version": "1"}}))?;
c.notify("notifications/initialized");
let mut live = Live { link, next_id: 1 };
live.request("initialize", json!({"protocolVersion": PROTOCOL, "capabilities": {}, "clientInfo": {"name": "AnyBot", "version": "1"}}))?;
live.notify("notifications/initialized");
let mut tools = vec![];
let mut cursor: Option<String> = None;
for _ in 0..20 {
let r = c.request("tools/list", match &cursor { Some(x) => json!({"cursor": x}), None => json!({}) })?;
let r = live.request("tools/list", match &cursor { Some(x) => json!({"cursor": x}), None => json!({}) })?;
for t in r["tools"].as_array().into_iter().flatten() {
tools.push(ToolInfo { server: s.name.clone(), name: t["name"].as_str().unwrap_or("").into(), description: t["description"].as_str().unwrap_or("").into(), schema: t["inputSchema"].clone() });
}
@ -239,31 +246,37 @@ fn connect(s: &Server) -> Result<Conn, String> {
break;
}
}
c.tools = tools;
Ok(c)
Ok(Conn { live: Mutex::new(live), tools, config: s.clone() })
}
/// Start (or keep) the enabled servers; stop the rest. Returns errors per server.
/// Connecting happens outside the table's lock (a slow server must not freeze the UI's
/// status line, which reads that lock every second).
pub fn sync(servers: &[Server]) -> Vec<(String, String)> {
let mut errs = vec![];
let mut to_connect: Vec<Server> = vec![];
{
let mut map = conns().lock().unwrap();
let wanted: Vec<&Server> = servers.iter().filter(|s| s.enabled).collect();
map.retain(|name, c| {
let keep = wanted.iter().any(|s| &s.name == name && **s == c.config);
if !keep {
if let Link::Stdio { child, .. } = &mut c.link {
if let Link::Stdio { child, .. } = &mut c.live.lock().unwrap().link {
let _ = child.kill();
}
}
keep
});
for s in wanted {
if map.contains_key(&s.name) {
continue;
if !map.contains_key(&s.name) {
to_connect.push(s.clone());
}
match connect(s) {
}
}
for s in to_connect {
match connect(&s) {
Ok(c) => {
map.insert(s.name.clone(), c);
conns().lock().unwrap().insert(s.name.clone(), Arc::new(c));
}
Err(e) => errs.push((s.name.clone(), e)),
}
@ -305,9 +318,12 @@ pub fn resolve(name: &str) -> Option<(String, String)> {
pub fn call(name: &str, args: &str) -> (String, bool, Option<String>) {
let Some((server, tool)) = resolve(name) else { return (format!("沒有「{name}」這個 MCP 工具(伺服器可能已關閉)"), false, None) };
let arguments: Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(json!({}));
let mut map = conns().lock().unwrap();
let Some(c) = map.get_mut(&server) else { return ("MCP 伺服器沒有連線".into(), false, None) };
match c.request("tools/call", json!({"name": tool, "arguments": arguments})) {
// Copy the server's own handle out of the table, then lock its link alone: a two-minute
// call to one server used to block every other MCP tool and the UI's status line.
let conn = conns().lock().unwrap().get(&server).cloned();
let Some(conn) = conn else { return ("MCP 伺服器沒有連線".into(), false, None) };
let mut live = conn.live.lock().unwrap();
match live.request("tools/call", json!({"name": tool, "arguments": arguments})) {
Ok(r) => {
let mut text = String::new();
let mut image = None;
@ -336,8 +352,8 @@ pub fn call(name: &str, args: &str) -> (String, bool, Option<String>) {
}
pub fn shutdown() {
for (_, c) in conns().lock().unwrap().iter_mut() {
if let Link::Stdio { child, .. } = &mut c.link {
for c in conns().lock().unwrap().values() {
if let Link::Stdio { child, .. } = &mut c.live.lock().unwrap().link {
let _ = child.kill();
}
}

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

@ -125,6 +125,8 @@ fn is_zero(n: &u64) -> bool {
pub const BOT_COMPUTER_NAME: &str = "Bot 的電腦";
pub const THIS_COMPUTER_NAME: &str = "你的電腦";
/// A brain on a server, when the person's own computer lends it hands (then that one is 你的電腦).
pub const BRAIN_HOST_NAME: &str = "大腦伺服器";
impl Message {
pub fn user(text: &str) -> Self {
@ -405,10 +407,21 @@ impl State {
}
pub fn load() -> State {
let mut s = std::fs::read_to_string(Self::path())
.ok()
.and_then(|s| serde_json::from_str::<State>(&s).ok())
.unwrap_or_else(State::first_run);
let p = Self::path();
let raw = std::fs::read_to_string(&p);
let parsed = raw.as_ref().ok().and_then(|s| serde_json::from_str::<State>(s).ok());
let mut s = match parsed {
Some(s) => s,
None => {
// A damaged save must not vanish quietly: keep the bytes (so the person can
// recover them), then start fresh. Silently wiping left no trace before.
if let Ok(raw) = raw.as_ref() {
let damaged = p.with_extension(format!("json.damaged-{}", chrono::Utc::now().timestamp()));
let _ = std::fs::write(&damaged, raw);
}
State::first_run()
}
};
s.drop_untouched_samples();
for b in s.bots.iter_mut() {
b.repair_interrupted();
@ -424,11 +437,16 @@ impl State {
}
if let Ok(s) = serde_json::to_string_pretty(self) {
let tmp = p.with_extension("json.tmp");
if std::fs::write(&tmp, s).is_ok() {
// Flush the bytes before renaming, so a power cut cannot leave a renamed-but-empty
// file where load() would find it (and treat it as a first run).
if let Ok(mut f) = std::fs::File::create(&tmp) {
use std::io::Write;
if f.write_all(s.as_bytes()).and_then(|_| f.sync_all()).is_ok() {
let _ = std::fs::rename(&tmp, &p);
}
}
}
}
pub fn provider(&self, kind: ProviderKind) -> ProviderSettings {
self.providers.get(kind.key()).cloned().unwrap_or_default()
@ -481,12 +499,15 @@ impl State {
pub fn take_in_bots(&mut self, other: &State) -> usize {
let last = |b: &Bot| b.messages.last().map(|m| (m.at, m.text.clone()));
let mut n = 0;
let mut reid: Vec<(u64, u64)> = vec![];
for b in &other.bots {
if self.bots.iter().any(|o| o.name == b.name && (b.messages.is_empty() || (o.messages.len() == b.messages.len() && last(o) == last(b)))) {
continue;
}
let mut b = b.clone();
let old_id = b.id;
b.id = self.next_id.max(1);
reid.push((old_id, b.id));
self.next_id = b.id + 1;
for r in b.routines.iter_mut() {
r.id = self.next_routine_id.max(1);
@ -495,6 +516,20 @@ impl State {
self.bots.push(b);
n += 1;
}
// Group members are bot ids from the other machine: follow them to the copies that
// just landed, or a group answers with bots that have nothing to do with it.
if !reid.is_empty() {
for g in self.bots.iter_mut() {
if g.members.is_empty() {
continue;
}
for m in g.members.iter_mut() {
if let Some((_, new)) = reid.iter().find(|(o, _)| *o == *m) {
*m = *new;
}
}
}
}
// Keys, skills and connectors this computer doesn't have yet.
for (k, v) in &other.providers {
let mine = self.providers.entry(k.clone()).or_default();

View File

@ -90,6 +90,7 @@ pub fn screensaver_active() -> bool {
std::thread::sleep(std::time::Duration::from_millis(20));
}
let _ = c.kill();
let _ = c.wait();
false
}
@ -116,10 +117,10 @@ pub fn banner(title: &str, body: &str) {
if !cfg!(target_os = "macos") {
// The desktop's notification service (GNOME, KDE, XFCE…) through notify-send.
let body: String = body.chars().take(180).collect();
let _ = Command::new("notify-send").args(["-a", "AnyBot", title, &body]).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn();
let _ = crate::computer::spawn_reaped(&mut Command::new("notify-send").args(["-a", "AnyBot", title, &body]).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()));
return;
}
let _ = Command::new("osascript").arg("-e").arg(banner_script(title, body)).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn();
let _ = crate::computer::spawn_reaped(&mut Command::new("osascript").arg("-e").arg(banner_script(title, body)).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()));
}
#[cfg(test)]

View File

@ -87,8 +87,10 @@ pub fn describe(name: &str, args: &Value) -> String {
}
fn find<'a>(pages: &'a [Page], bot: u64, id: Option<u64>, title: &str) -> Option<&'a Page> {
// A page belongs to the bot that made it: ids are guesses a model can make, and reading
// (or overwriting) another bot's page by id must not work.
match id {
Some(i) => pages.iter().find(|p| p.id == i),
Some(i) => pages.iter().find(|p| p.id == i && p.bot == bot),
None => pages.iter().find(|p| p.bot == bot && same_title(&p.title, title)),
}
}

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

@ -37,10 +37,16 @@ pub struct Screen {
struct Live {
tx: Sender<Cmd>,
screens: Vec<Screen>,
/// Which started session this is, so its own thread (and only it) can clear LIVE when it ends.
id: u64,
}
static LIVE: Mutex<Option<Live>> = Mutex::new(None);
static LAST_ERROR: Mutex<String> = Mutex::new(String::new());
/// One start at a time: two concurrent ensure() calls used to open two portal sessions, and
/// the first to end then cleared the second's LIVE entry (the window went blind).
static ENSURE: Mutex<()> = Mutex::new(());
static NEXT_SESSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
fn token_file() -> std::path::PathBuf {
crate::model::State::path().parent().map(|d| d.join("wayland-remote-token.txt")).unwrap_or_default()
@ -63,9 +69,11 @@ pub fn last_error() -> String {
/// Start the session if it isn't running (the first time, the desktop asks the person).
fn ensure() -> Option<Vec<Screen>> {
let _start = ENSURE.lock().unwrap_or_else(|e| e.into_inner());
if let Some(l) = LIVE.lock().unwrap().as_ref() {
return Some(l.screens.clone());
}
let id = NEXT_SESSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let (ready_tx, ready_rx) = channel::<Result<Vec<Screen>, String>>();
let (tx, rx) = channel::<Cmd>();
std::thread::spawn(move || {
@ -147,12 +155,17 @@ fn ensure() -> Option<Vec<Screen>> {
}
}
let _ = session.close().await;
*LIVE.lock().unwrap() = None;
// Only the session that is still the current one retires the slot (an older
// session ending must not wipe the newer one that replaced it).
let mut live = LIVE.lock().unwrap();
if live.as_ref().map(|l| l.id) == Some(id) {
*live = None;
}
});
});
match ready_rx.recv_timeout(Duration::from_secs(120)) {
Ok(Ok(screens)) => {
*LIVE.lock().unwrap() = Some(Live { tx, screens: screens.clone() });
*LIVE.lock().unwrap() = Some(Live { tx, screens: screens.clone(), id });
Some(screens)
}
Ok(Err(e)) => {

View File

@ -114,7 +114,8 @@ pub fn system_open(target: impl AsRef<std::ffi::OsStr>) -> std::process::Command
}
pub fn open_with_system(target: &str) {
if let Err(e) = system_open(target).spawn() {
let mut c = system_open(target);
if let Err(e) = crate::computer::spawn_reaped(&mut c) {
eprintln!("anybot: couldn't open {target}: {e}");
}
}
@ -138,7 +139,13 @@ fn clip(t: String) -> String {
}
fn read_text(path: &Path) -> Result<String, String> {
let bytes = std::fs::read(path).map_err(|e| format!("讀不到檔案:{e}"))?;
use std::io::Read;
// Read only the head of the file: a preview never shows more than clip() keeps (200 K
// chars), and reading a multi-GB log whole on the UI thread froze the whole window.
const HEAD: u64 = 4 << 20;
let mut f = std::fs::File::open(path).map_err(|e| format!("讀不到檔案:{e}"))?;
let mut bytes = Vec::new();
f.by_ref().take(HEAD).read_to_end(&mut bytes).map_err(|e| format!("讀不到檔案:{e}"))?;
if bytes.iter().take(8000).any(|b| *b == 0) {
return Err("這是二進位檔案,沒辦法當文字顯示。".into());
}
@ -456,15 +463,36 @@ fn md_table(rows: &[Vec<String>]) -> String {
s
}
/// Find `<tag` followed by `>` or a space, ASCII case-insensitive, on the original string.
/// (Lowercasing the whole page first shifts byte offsets — "İ" becomes 3 bytes when lowered —
/// and cutting the original at those offsets panics.)
fn find_open_tag(s: &str, tag: &str) -> Option<usize> {
let (b, t) = (s.as_bytes(), tag.as_bytes());
(0..b.len()).find(|&i| {
b[i] == b'<'
&& b.len() >= i + 1 + t.len()
&& b[i + 1..].iter().zip(t).all(|(a, c)| a.eq_ignore_ascii_case(c))
&& matches!(b.get(i + 1 + t.len()), Some(c) if *c == b'>' || c.is_ascii_whitespace())
})
}
/// `needle` (ASCII) in `hay`, ASCII case-insensitive, as a byte offset.
fn find_ci(hay: &str, needle: &str) -> Option<usize> {
let (h, n) = (hay.as_bytes(), needle.as_bytes());
if n.is_empty() || h.len() < n.len() {
return None;
}
(0..=h.len() - n.len()).find(|&i| h[i..i + n.len()].iter().zip(n).all(|(a, c)| a.eq_ignore_ascii_case(c)))
}
/// A web page as Markdown: headings, paragraphs, lists, tables, bold, links ("text (url)").
/// Scripts, styles and the <head> are dropped.
pub fn html_markdown(html: &str) -> String {
let mut s = html.to_string();
for tag in ["head", "script", "style", "noscript", "svg", "template"] {
loop {
let low = s.to_lowercase();
let Some(a) = low.find(&format!("<{tag}")).filter(|a| low[a + tag.len() + 1..].starts_with(|c: char| c == '>' || c.is_whitespace())) else { break };
let end = low[a..].find(&format!("</{tag}>")).map(|e| a + e + tag.len() + 3).unwrap_or(s.len());
let Some(a) = find_open_tag(&s, tag) else { break };
let end = find_ci(&s[a..], &format!("</{tag}>")).map(|e| a + e + tag.len() + 3).unwrap_or(s.len());
s.replace_range(a..end, "");
}
}
@ -586,7 +614,12 @@ pub fn html_markdown(html: &str) -> String {
}
fn load_sheet(path: &Path, name: &str) -> Result<Preview, String> {
// calamine parses the whole workbook on the UI thread: a huge .xlsx froze the window.
// (CSV/TSV are fine: read_text reads only the head.)
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
if matches!(ext.as_str(), "xlsx" | "xls" | "ods") && std::fs::metadata(path).map(|m| m.len() > 50 << 20).unwrap_or(false) {
return Err("這個試算表太大(超過 50 MB),在 AnyBot 裡預覽會卡住;請用其他 App 打開。".into());
}
if ext == "csv" || ext == "tsv" {
let text = read_text(path)?;
let sep = if ext == "tsv" { '\t' } else { ',' };
@ -623,6 +656,11 @@ fn load_sheet(path: &Path, name: &str) -> Result<Preview, String> {
/// An Excel date number (days since 1899-12-30) as "2026-10-31" or "2026-10-29 20:13".
pub fn excel_date(v: f64) -> String {
// A cell formatted as a date can hold any number (Excel cells go to 1e308); beyond any
// real date, show the number — the date maths would overflow chrono and panic.
if !v.is_finite() || v.abs() > 3_000_000.0 {
return if v.fract().abs() < 0.000001 { format!("{}", v as i64) } else { format!("{v}") };
}
let base = chrono::NaiveDate::from_ymd_opt(1899, 12, 30).unwrap().and_hms_opt(0, 0, 0).unwrap();
let t = base + chrono::Duration::milliseconds((v * 86_400_000.0).round() as i64);
if v.fract().abs() < 1e-9 {

View File

@ -188,7 +188,9 @@ pub enum StreamEvent {
/// The next model step starts: add a fresh reply bubble.
NextStep(String),
/// The oldest `n` messages were folded into this summary.
Compacted(usize, Message),
/// Older messages were folded into this summary; `cut` is the count in the model's view and
/// `boundary` the timestamp of the first kept message (the window's own copy may be longer).
Compacted(usize, Message, i64),
/// A message the person sent mid-turn was handed to the model (add it to the chat).
Steered(Message),
/// Do something in the app (routines, bots, settings) and answer on the sender.
@ -275,6 +277,10 @@ fn agent() -> ureq::Agent {
.http_status_as_error(false)
.timeout_connect(Some(Duration::from_millis(CONNECT_TIMEOUT_MS)))
.timeout_recv_response(Some(Duration::from_secs(FIRST_RESPONSE_TIMEOUT_SECS)))
// A stream that goes silent (a NAT that dropped the mapping, a server that died without
// a FIN) must end: without a read timeout the lines() loop blocks forever, and every
// such turn leaks a thread and its socket. 5 minutes of total silence is a dead stream.
.timeout_per_read(Some(Duration::from_secs(300)))
.build();
config.into()
}
@ -414,8 +420,12 @@ pub fn run_step(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEven
}
}
fn read_sse(body: ureq::Body, cancel: &AtomicBool, send: &dyn Fn(StreamEvent), mut on_data: impl FnMut(&str, &str) -> bool) -> Result<(), Failure> {
let reader = BufReader::new(body.into_reader());
fn read_sse(body: ureq::Body, cancel: &AtomicBool, send: &dyn Fn(StreamEvent), on_data: impl FnMut(&str, &str) -> bool) -> Result<(), Failure> {
read_sse_from(body.into_reader(), cancel, send, on_data)
}
fn read_sse_from(r: impl std::io::Read, cancel: &AtomicBool, send: &dyn Fn(StreamEvent), mut on_data: impl FnMut(&str, &str) -> bool) -> Result<(), Failure> {
let reader = BufReader::new(r);
let mut event = String::new();
for line in reader.lines() {
if cancel.load(Ordering::Relaxed) {
@ -545,6 +555,19 @@ pub fn set_eyes(v: Vec<Eyes>) {
*EYES.lock().unwrap() = v;
}
/// The helper list, the description cache and the failure list are process-wide: tests that
/// touch them must run one at a time, from a clean slate, or they undo each other (the
/// describe cache hit in one test made another's helper never get called).
#[cfg(test)]
pub fn vision_test_guard() -> std::sync::MutexGuard<'static, ()> {
static L: std::sync::Mutex<()> = std::sync::Mutex::new(());
let g = L.lock().unwrap_or_else(|e| e.into_inner());
notes().lock().unwrap().clear();
failed_eyes().lock().unwrap().clear();
set_eyes(vec![]);
g
}
/// Models likely to see pictures, by provider.
pub fn seeing_models(kind: ProviderKind) -> &'static [&'static str] {
match kind {
@ -750,15 +773,34 @@ fn stream_openai_compatible(req: &ChatRequest, cancel: &AtomicBool, send: &dyn F
body["tools"] = json!(req.tools.iter().map(|t| json!({"type": "function", "function": t})).collect::<Vec<_>>());
}
let url = format!("{}/chat/completions", req.base_url);
let mut r = agent().post(&url).header("Content-Type", "application/json");
let mut headers: Vec<(String, String)> = vec![];
if !req.api_key.is_empty() {
r = r.header("Authorization", &format!("Bearer {}", req.api_key));
headers.push(("Authorization".into(), format!("Bearer {}", req.api_key)));
}
if req.kind == ProviderKind::OpenRouter {
r = r.header("X-Title", "AnyBot");
headers.push(("X-Title".into(), "AnyBot".into()));
}
if req.kind == ProviderKind::OpenCodeGo {
r = r.header("x-opencode-session", &opencode_session(req));
headers.push(("x-opencode-session".into(), opencode_session(req)));
}
// A brain on a server, a model on the person's own network: through their computer (the
// whole answer comes back at once, so no word-by-word streaming).
let via_hands: Option<Vec<u8>> = match crate::remote::model_hands_for(&url) {
Some(hand) => {
let (status, bytes) = crate::remote::model_via_hands(&hand, &url, &headers, &body.to_string()).map_err(Failure::retry)?;
if status >= 400 {
return Err(http_error(status, &String::from_utf8_lossy(&bytes), None));
}
Some(bytes)
}
None => None,
};
let resp = match via_hands {
Some(_) => None,
None => {
let mut r = agent().post(&url).header("Content-Type", "application/json");
for (k, v) in &headers {
r = r.header(k, v);
}
// SAND-E0418 turnBackendUnreachable: retryable.
let resp = r.send_json(&body).map_err(|e| Failure::retry(connect_error(req.kind, &e.to_string())))?;
@ -768,11 +810,18 @@ fn stream_openai_compatible(req: &ChatRequest, cancel: &AtomicBool, send: &dyn F
let text = resp.into_body().read_to_string().unwrap_or_default();
return Err(http_error(status, &text, ra));
}
Some(resp)
}
};
let mut out = StepOutput::default();
// Tool calls arrive in pieces keyed by index.
let mut pending: Vec<(String, String, String)> = vec![];
let mut error = None;
read_sse(resp.into_body(), cancel, send, |_ev, data| {
let reader: Box<dyn std::io::Read> = match (resp, via_hands) {
(Some(r), _) => Box::new(r.into_body().into_reader()),
(None, b) => Box::new(std::io::Cursor::new(b.unwrap_or_default())),
};
read_sse_from(reader, cancel, send, |_ev, data| {
if data == "[DONE]" {
return false;
}
@ -798,7 +847,20 @@ fn stream_openai_compatible(req: &ChatRequest, cancel: &AtomicBool, send: &dyn F
}
if let Some(calls) = delta.get("tool_calls").and_then(Value::as_array) {
for c in calls {
let i = c["index"].as_u64().unwrap_or(pending.len() as u64) as usize;
let i = match c["index"].as_u64() {
Some(i) => i as usize,
// Some OpenAI-compatible proxies drop the index: a part carrying an
// id or a name starts a new call; an arguments-only part continues the
// last one (starting a fresh slot used to lose the call's tail).
None => {
let starts = c["id"].is_string() || c["function"]["name"].is_string();
if starts || pending.is_empty() {
pending.len()
} else {
pending.len() - 1
}
}
};
while pending.len() <= i {
pending.push(Default::default());
}
@ -1335,6 +1397,7 @@ mod tests {
#[test]
fn a_model_that_cannot_see_gets_no_pictures() {
let _g = vision_test_guard();
assert!(image_refused("錯誤 400:At most 0 image(s) may be provided in one prompt. (parameter=image)"));
assert!(image_refused("This model does not support image input"));
assert!(image_refused("unknown variant `image_url`, expected `text`"));
@ -1425,6 +1488,7 @@ mod tests {
#[test]
fn a_model_that_sees_describes_pictures_for_one_that_cannot() {
let _g = vision_test_guard();
let (blind_url, blind_rx) = fake_model(true, "好的,我看到描述了");
let (eyes_url, eyes_rx) = fake_model(false, "一張報表截圖,上面寫著「十月營收 42 萬」。");
set_eyes(vec![

View File

@ -142,7 +142,8 @@ pub struct Hub {
cmds: Mutex<Vec<Cmd>>,
repaint: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
settings: Mutex<Settings>,
fails: Mutex<(u32, Option<Instant>)>,
/// Wrong logins, counted per source (one noisy LAN device must not lock out the owner).
fails: Mutex<HashMap<String, (u32, Instant)>>,
relay_status: Mutex<String>,
sync: Mutex<Vec<SyncReq>>,
}
@ -162,7 +163,7 @@ pub struct SyncReq {
pub fn hub() -> &'static Hub {
static H: OnceLock<Hub> = OnceLock::new();
H.get_or_init(|| Hub { view: Mutex::new(View::default()), changed: Condvar::new(), cmds: Mutex::new(vec![]), repaint: Mutex::new(None), settings: Mutex::new(Settings::default()), fails: Mutex::new((0, None)), relay_status: Mutex::new(String::new()), sync: Mutex::new(vec![]) })
H.get_or_init(|| Hub { view: Mutex::new(View::default()), changed: Condvar::new(), cmds: Mutex::new(vec![]), repaint: Mutex::new(None), settings: Mutex::new(Settings::default()), fails: Mutex::new(HashMap::new()), relay_status: Mutex::new(String::new()), sync: Mutex::new(vec![]) })
}
static VERSION: AtomicU64 = AtomicU64::new(1);
@ -487,28 +488,35 @@ pub fn role_allows(role: &str, method: &str, path: &[&str]) -> bool {
}
fn login(req: &Req) -> Resp {
// One counter per source address, cleaned as it is touched.
let ip = req.peer.split(':').next().unwrap_or(&req.peer).to_string();
{
let mut f = hub().fails.lock().unwrap();
if let (n, Some(t)) = *f {
if n >= MAX_FAILS && t.elapsed() < Duration::from_secs(60) {
f.retain(|_, (_, t)| t.elapsed() < Duration::from_secs(60));
if let Some((n, t)) = f.get(&ip) {
if *n >= MAX_FAILS && t.elapsed() < Duration::from_secs(60) {
return Resp::error(429, "密碼錯太多次,請一分鐘後再試");
}
if t.elapsed() >= Duration::from_secs(60) {
*f = (0, None);
}
}
}
let b = req.json();
let pw = b["password"].as_str().unwrap_or("");
let stored = hub().settings().password;
if stored.is_empty() || !httpd::check_password(pw, &stored) {
{
let mut f = hub().fails.lock().unwrap();
*f = (f.0 + 1, Some(Instant::now()));
let e = f.entry(ip.clone()).or_insert((0, Instant::now()));
if e.1.elapsed() >= Duration::from_secs(60) {
*e = (0, Instant::now());
}
e.0 += 1;
e.1 = Instant::now();
}
// Slow guessing down a little more.
std::thread::sleep(Duration::from_millis(600));
return Resp::error(401, if stored.is_empty() { "這台 AnyBot 還沒設定手機密碼" } else { "密碼不對" });
}
*hub().fails.lock().unwrap() = (0, None);
hub().fails.lock().unwrap().remove(&ip);
let token = httpd::random_hex(32);
let name = b["device"].as_str().filter(|d| !d.trim().is_empty()).unwrap_or("手機").chars().take(40).collect::<String>();
let d = Device { name, token_hash: httpd::sha256_hex(&token), created: chrono::Utc::now().timestamp(), last_seen: chrono::Utc::now().timestamp() };
@ -619,6 +627,10 @@ fn device_run(v: &Value, s: &Settings, via_relay: bool, from: &str) -> Resp {
let tool = v["tool"].as_str().unwrap_or("");
let args = v["args"].as_str().unwrap_or("{}");
let who = v["who"].as_str().unwrap_or("遠端 AnyBot");
// The brain's model request to a model only this computer's network reaches.
if tool == MODEL_FETCH {
return model_fetch_here(args);
}
if crate::computer::is_computer_tool(tool) {
let (text, ok, img) = crate::computer::run_by(tool, args, who);
return Resp::json(&json!({"text": text, "ok": ok, "image": img}));
@ -645,6 +657,98 @@ fn device_run(v: &Value, s: &Settings, via_relay: bool, from: &str) -> Resp {
Resp::error(400, "這個工具不能遠端使用")
}
// ---------- a model on the person's network, for a brain on a server ----------
// A brain in the cloud can't reach a model at a private address (a Qwen server at work, Ollama at
// home). Its hands can: the brain hands them the request and gets the whole answer back.
const MODEL_FETCH: &str = "model_fetch";
/// Private, link-local or loopback address (by name: localhost and *.local).
fn private_host(host: &str) -> bool {
let h = host.trim_matches(['[', ']']);
if h == "localhost" || h.ends_with(".local") {
return true;
}
match h.parse::<std::net::IpAddr>() {
// Not link-local: a cloud VM's metadata service (169.254.169.254) must never be
// reachable through the hands, only real model servers on the person's network.
Ok(std::net::IpAddr::V4(a)) => (a.is_private() || a.is_loopback()) && !a.is_link_local(),
Ok(std::net::IpAddr::V6(a)) => (a.is_loopback() || (a.segments()[0] & 0xfe00) == 0xfc00) && !a.is_unicast_link_local(),
Err(_) => false,
}
}
fn host_port(url: &str) -> Option<(String, u16)> {
let (scheme, rest) = url.split_once("://")?;
let auth = rest.split('/').next()?;
let auth = auth.rsplit('@').next()?;
let (h, p) = match auth.rsplit_once(':') {
Some((h, p)) if !h.ends_with(']') || auth.starts_with('[') => (h.to_string(), p.parse().ok()?),
_ => (auth.to_string(), if scheme == "https" { 443 } else { 80 }),
};
Some((h, p))
}
/// The hands to send a model request through: the model is at a private address this brain
/// can't open a connection to, and the person's computer lends this brain its hands.
pub fn model_hands_for(url: &str) -> Option<String> {
let s = hub().settings();
let hand = my_hands(&s.host_id).into_iter().next()?;
let (h, p) = host_port(url)?;
if !private_host(&h) || h == "localhost" || h.starts_with("127.") {
return None;
}
static SEEN: Mutex<Option<HashMap<String, (bool, std::time::Instant)>>> = Mutex::new(None);
let key = format!("{h}:{p}");
if let Some((reach, at)) = SEEN.lock().unwrap().get_or_insert_with(HashMap::new).get(&key) {
if at.elapsed().as_secs() < 300 {
return if *reach { None } else { Some(hand) };
}
}
use std::net::ToSocketAddrs;
let reach = (h.as_str(), p).to_socket_addrs().ok().and_then(|mut a| a.next()).map(|a| std::net::TcpStream::connect_timeout(&a, std::time::Duration::from_millis(1500)).is_ok()).unwrap_or(false);
SEEN.lock().unwrap().get_or_insert_with(HashMap::new).insert(key, (reach, std::time::Instant::now()));
if reach { None } else { Some(hand) }
}
/// Send a model request (POST, JSON) through the hands; gives back its status and whole body.
pub fn model_via_hands(hand: &str, url: &str, headers: &[(String, String)], body: &str) -> Result<(u16, Vec<u8>), String> {
let args = json!({"url": url, "headers": headers, "body": body}).to_string();
let req = json!({"tool": MODEL_FETCH, "args": args, "who": "大腦"}).to_string();
let name = peer_name(hand);
let whom = if name.is_empty() { "你的電腦".to_string() } else { format!("「{name}」") };
let r = crate::tunnel::call(hand, "POST", "/api/device/run", &[], req.as_bytes(), 600).ok_or_else(|| format!("模型在只有{whom}連得到的網路上,但大腦現在和帳號伺服器沒有連線"))?;
let a = r.map_err(|e| format!("透過{whom}連模型失敗:{e}"))?;
let v = answer_value(a.status, &a.body, &whom)?;
if let Some(e) = v["error"].as_str() {
return Err(format!("{whom}也連不到模型:{e}"));
}
Ok((v["status"].as_u64().unwrap_or(502) as u16, v["text"].as_str().unwrap_or("").as_bytes().to_vec()))
}
/// On the hands: make the brain's model request, only to an address on this computer's network.
fn model_fetch_here(args: &str) -> Resp {
let v: Value = serde_json::from_str(args).unwrap_or(Value::Null);
let url = v["url"].as_str().unwrap_or("");
if !host_port(url).map(|(h, _)| private_host(&h)).unwrap_or(false) {
return Resp::error(403, "只幫大腦連這台電腦網路裡的模型");
}
let mut r = agent(600).post(url).header("Content-Type", "application/json");
for h in v["headers"].as_array().into_iter().flatten() {
if let (Some(k), Some(val)) = (h[0].as_str(), h[1].as_str()) {
r = r.header(k, val);
}
}
match r.config().http_status_as_error(false).build().send(v["body"].as_str().unwrap_or("").as_bytes()) {
Ok(mut resp) => {
let status = resp.status().as_u16();
let text = resp.body_mut().with_config().limit(50 * 1024 * 1024).read_to_string().unwrap_or_default();
Resp::json(&json!({"status": status, "text": text}))
}
Err(e) => Resp::json(&json!({"error": why_unreachable(&e)})),
}
}
// ---------- the local server ----------
static LAN_GEN: AtomicU64 = AtomicU64::new(0);
@ -779,6 +883,11 @@ fn base(url: &str) -> String {
/// Sign in (or create the account) at the relay and register this AnyBot as one of its hosts.
/// Returns the settings to keep.
pub fn relay_join(base_settings: &Settings, url: &str, user: &str, password: &str, create: bool, invite: &str) -> Result<Settings, String> {
// The relay signs people in by the trimmed, lowered name; the backup key must be derived
// from exactly the same string, or two computers signing in as "Dan" and "dan" get keys
// that cannot open each other's backups.
let user = user.trim().to_lowercase();
let user = user.as_str();
let host_name = base_settings.host_name.as_str();
let url = base(url);
// Plain http only inside one machine or one Docker network (e.g. http://relay:8080).
@ -931,6 +1040,9 @@ pub fn backup_now(s: &Settings, state_json: &[u8]) -> Result<usize, String> {
/// Download and open the newest backup of this account (for a new computer).
pub fn restore(url: &str, user: &str, password: &str) -> Result<Vec<u8>, String> {
// Same name shape the relay (and relay_join) use for the backup key.
let user = user.trim().to_lowercase();
let user = user.as_str();
let url = base(url);
let v = relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": "還原備份"}))?;
let session = v["token"].as_str().unwrap_or("").to_string();
@ -1222,12 +1334,39 @@ fn hands_ids() -> &'static Mutex<std::collections::HashSet<String>> {
H.get_or_init(Mutex::default)
}
/// The account's computers as last seen: id → (name, the brain it uses, lends its hands).
fn peer_rows() -> &'static Mutex<HashMap<String, (String, String, bool)>> {
static P: OnceLock<Mutex<HashMap<String, (String, String, bool)>>> = OnceLock::new();
P.get_or_init(Mutex::default)
}
/// Computers of the account that lend their hands to this brain (`me`), by id.
pub fn my_hands(me: &str) -> Vec<String> {
if me.is_empty() {
return vec![];
}
let mut v: Vec<String> = peer_rows().lock().unwrap().iter().filter(|(_, (_, uses, hands))| *hands && uses == me).map(|(id, _)| id.clone()).collect();
v.sort();
v
}
/// A computer's own name as the account server knows it.
pub fn peer_name(id: &str) -> String {
peer_rows().lock().unwrap().get(id).map(|r| r.0.clone()).unwrap_or_default()
}
pub fn devices_online(s: &Settings) -> Vec<(String, String, bool)> {
if s.relay_url.is_empty() {
return vec![];
}
let Ok(mut r) = agent(10).get(&format!("{}/relay/host/peers", s.relay_url)).header("Authorization", &format!("Bearer {}", s.host_token)).call() else { return vec![] };
let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null);
*peer_rows().lock().unwrap() = v["hosts"]
.as_array()
.into_iter()
.flatten()
.filter_map(|h| Some((h["id"].as_str()?.to_string(), (h["name"].as_str().unwrap_or("").to_string(), h["uses"].as_str().unwrap_or("").to_string(), h["hands"].as_bool() == Some(true)))))
.collect();
*hands_ids().lock().unwrap() = v["hosts"].as_array().into_iter().flatten().filter(|h| h["hands"].as_bool() == Some(true) || !h["uses"].as_str().unwrap_or("").is_empty()).filter_map(|h| h["id"].as_str().map(String::from)).collect();
v["hosts"].as_array().map(|a| a.iter().map(|h| (h["id"].as_str().unwrap_or("").to_string(), h["name"].as_str().unwrap_or("").to_string(), h["online"].as_bool().unwrap_or(false))).collect()).unwrap_or_default()
}
@ -1246,6 +1385,61 @@ pub fn page() -> Resp {
mod tests {
use super::*;
#[test]
fn model_requests_go_only_to_private_addresses() {
assert!(private_host("10.184.10.252") && private_host("192.168.1.5") && private_host("localhost") && private_host("nas.local"));
assert!(!private_host("api.x.ai") && !private_host("8.8.8.8"));
// A cloud VM's metadata service answers on link-local: the hands must never reach it.
assert!(!private_host("169.254.169.254") && !private_host("169.254.0.1"));
assert_eq!(host_port("http://10.184.10.252:8000/v1"), Some(("10.184.10.252".into(), 8000)));
assert_eq!(host_port("https://api.x.ai/v1"), Some(("api.x.ai".into(), 443)));
assert_eq!(model_fetch_here(&json!({"url": "https://api.x.ai/v1/chat/completions"}).to_string()).status, 403);
assert_eq!(model_fetch_here(&json!({"url": "http://169.254.169.254/latest/meta-data/"}).to_string()).status, 403);
}
#[test]
fn lan_login_lock_is_per_device() {
// One noisy device guessing passwords must not lock the owner's phone out (a single
// global counter used to do exactly that).
let mut s = Settings { password: httpd::hash_password("pw-123"), ..Default::default() };
s.host_name = "測試 Mac".into();
hub().set_settings(&s);
let bad = |ip: &str| handle(Req { method: "POST".into(), path: "/api/login".into(), body: br#"{"password":"nope"}"#.to_vec(), peer: ip.into(), ..Default::default() }, false);
for _ in 0..MAX_FAILS {
assert_eq!(bad("192.168.1.66:5000").status, 401);
}
// The noisy device is now locked out…
assert_eq!(bad("192.168.1.66:5000").status, 429);
// …while every other device on the LAN can still sign in.
let ok = handle(Req { method: "POST".into(), path: "/api/login".into(), body: br#"{"password":"pw-123"}"#.to_vec(), peer: "192.168.1.77:5001".into(), ..Default::default() }, false);
assert_eq!(ok.status, 200);
}
#[test]
fn roles_gate_the_sync_and_hands_apis() {
// 大腦 sync (mirror) and 裝置連結 (device) are separate doors on the hands machine:
// a chat admin must not reach either, and a brain may not run tools through the sync door.
assert!(role_allows("device", "POST", &["api", "device", "run"]));
assert!(!role_allows("device", "GET", &["api", "sync"]));
assert!(role_allows("mirror", "GET", &["api", "sync"]));
assert!(role_allows("mirror", "POST", &["api", "sync", "edit"]));
assert!(!role_allows("mirror", "POST", &["api", "device", "run"]));
assert!(role_allows("admin", "POST", &["api", "bots", "1", "send"]));
assert!(!role_allows("admin", "POST", &["api", "device", "run"]));
assert!(!role_allows("admin", "GET", &["api", "sync"]));
assert!(role_allows("member", "POST", &["api", "bots", "1", "send"]));
assert!(!role_allows("member", "POST", &["api", "bots", "1", "stop"]));
// A hands machine takes tool orders only through the relay, only when the person opened
// it (裝置連結), and only from the brain it was told to serve.
let open_for_one = Settings { device_link: true, hands_for: "brain1".into(), hands_for_name: "雲端大腦".into(), ..Default::default() };
assert_eq!(device_run(&json!({"tool": "x"}), &open_for_one, false, "brain1").status, 403);
assert_eq!(device_run(&json!({"tool": "x"}), &Settings { device_link: false, hands_for: "brain1".into(), ..Default::default() }, true, "brain1").status, 403);
assert_eq!(device_run(&json!({"tool": "x"}), &open_for_one, true, "other-brain").status, 403);
// The chosen brain reaches past the gates (and gets "this tool can't run remotely" for x).
assert_eq!(device_run(&json!({"tool": "x"}), &open_for_one, true, "brain1").status, 400);
}
#[test]
fn phone_login_and_api() {
let mut s = Settings { password: httpd::hash_password("pw-123"), ..Default::default() };

View File

@ -130,6 +130,26 @@ pub fn validate(mut s: Schedule) -> Result<Schedule, String> {
if s.frequency == Frequency::Minutely && s.interval < MIN_MINUTES {
return Err(format!("每 N 分鐘最少 {MIN_MINUTES} 分鐘"));
}
// Keep the "next run" maths (which searches interval-sized steps) bounded, and absurd
// intervals out: a huge hourly interval used to spin the UI thread for minutes on create.
let cap: u32 = match s.frequency {
Frequency::Minutely => 7 * 24 * 60,
Frequency::Hourly => 31 * 24,
Frequency::Daily => 366,
Frequency::Weekly => 53,
Frequency::Monthly => 120,
Frequency::Yearly => 10,
};
if s.interval > cap {
return Err(format!("間隔太大(這種頻率最多每 {cap} 單位一次)"));
}
if s.frequency == Frequency::Yearly {
let (m, d) = (s.month.unwrap_or(1), s.month_day.unwrap_or(1));
// 2 月 31 日永遠不會來:這個排程建立即失效,當初卻毫無警告。
if chrono::NaiveDate::from_ymd_opt(2024, m, d).is_none() {
return Err(format!("每年 {m} 月 {d} 日不存在,無法排入"));
}
}
match s.frequency {
Frequency::Weekly if s.days_of_week.is_empty() => return Err("每週的例行任務要指定星期幾(daysOfWeek,例如 [\"MO\",\"WE\"])".into()),
Frequency::Monthly if s.month_day.is_none() && s.weekday_of_month.is_none() => s.month_day = Some(1),
@ -245,7 +265,8 @@ fn day_matches(s: &Schedule, anchor: NaiveDate, d: NaiveDate) -> bool {
pub fn next_run(s: &Schedule, created: i64, after: i64) -> Option<i64> {
if s.once {
let first = exact_next(s, created, created - 1)?;
let first = flex(s, created, first);
// A one-time run is never flexed: flexing its only moment into the past made the task
// born already-late and it died silently (next_run = None).
return (first > after).then_some(first);
}
// Look a little earlier so a time pulled forward by "左右" isn't skipped.
@ -280,10 +301,16 @@ fn exact_next(s: &Schedule, created: i64, after: i64) -> Option<i64> {
}
if s.frequency == Frequency::Hourly {
let minute = parse_time(&s.time).map(|t| chrono::Timelike::minute(&t) as i64).unwrap_or(0);
let hour0 = created.div_euclid(3600);
let mut h = after.div_euclid(3600);
// Count hours in the schedule's own timezone: its hour line sits at a different
// epoch minute in +05:30/+05:45 — anchoring hours to UTC ran those zones 30/45 late.
let tz = tz_of(&s.timezone);
let at = Utc.timestamp_opt(created, 0).single()?.with_timezone(&tz);
// Offset seconds of that zone at `created` (local wall clock minus UTC).
let off = (at.naive_local() - at.naive_utc()).num_seconds() as i64;
let hour0 = (created + off).div_euclid(3600);
let mut h = (after + off).div_euclid(3600);
for _ in 0..(n * 2 + 2) {
let ts = h * 3600 + minute * 60;
let ts = h * 3600 + minute * 60 - off;
if ts > after && (h - hour0).rem_euclid(n) == 0 {
return Some(ts);
}
@ -300,8 +327,17 @@ fn exact_next(s: &Schedule, created: i64, after: i64) -> Option<i64> {
if !day_matches(s, anchor, d) {
continue;
}
// Skipped or doubled clock times (summer time) take the earliest valid moment.
let Some(at) = tz.from_local_datetime(&d.and_time(time)).earliest() else { continue };
// Skipped or doubled clock times (summer time): a doubled time takes its first
// occurrence; a skipped one (spring-forward) runs just after the gap, not never.
let local = d.and_time(time);
let at = match tz.from_local_datetime(&local) {
chrono::LocalResult::Single(at) => at,
chrono::LocalResult::Ambiguous(at, _) => at,
chrono::LocalResult::None => match tz.from_local_datetime(&(local + Duration::hours(1))).earliest() {
Some(at) => at,
None => continue,
},
};
let ts = at.timestamp();
if ts > after {
return Some(ts);

View File

@ -30,6 +30,33 @@ const DIM: Color = Color::DarkGrey;
const SPIN: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const UNIT: &str = "anybot.service";
/// Write a file all at once, as far as the filesystem allows: tmp file + rename. A Ctrl+C in
/// the middle of installing used to leave a half-written launcher/unit behind, and systemd
/// then ran (or refused to run) a broken service with no easy way to see why.
fn write_atomic(path: &std::path::Path, body: String) -> std::io::Result<()> {
let tmp = path.with_extension("anybot-tmp");
std::fs::write(&tmp, body)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
/// Quote a path for a systemd ExecStart line: a home folder with spaces (or a quote sign)
/// made the unit fail to start with no way to see why.
fn systemd_quote(p: &std::path::Path) -> String {
let mut out = String::from("\"");
for c in p.display().to_string().chars() {
match c {
'"' | '$' | '\\' | '`' => {
out.push('\\');
out.push(c);
}
c => out.push(c),
}
}
out.push('"');
out
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum Role {
Hands,
@ -766,7 +793,7 @@ impl Plan {
let bin = home.join(".local/bin");
std::fs::create_dir_all(&bin).map_err(|e| format!("建立 {} 失敗:{e}", bin.display()))?;
let launcher = bin.join("anybot-run");
std::fs::write(&launcher, launcher_script(self.role, &exe, &self.env)).map_err(|e| format!("寫入 {} 失敗:{e}", launcher.display()))?;
write_atomic(&launcher, launcher_script(self.role, &exe, &self.env)).map_err(|e| format!("寫入 {} 失敗:{e}", launcher.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@ -785,9 +812,9 @@ impl Plan {
};
let unit = format!(
"[Unit]\nDescription=AnyBot({what})\nAfter=network-online.target graphical-session.target\nWants=network-online.target\n\n[Service]\nExecStart={}\nRestart=always\nRestartSec=5\n\n[Install]\nWantedBy=default.target\n",
launcher.display()
systemd_quote(&launcher)
);
std::fs::write(units.join(UNIT), unit).map_err(|e| e.to_string())?;
write_atomic(&units.join(UNIT), unit).map_err(|e| e.to_string())?;
systemctl(&["daemon-reload"])?;
systemctl(&["enable", UNIT])?;
systemctl(&["restart", UNIT])?;
@ -815,7 +842,7 @@ fn refresh_service() -> i32 {
return 1;
}
};
if let Err(e) = std::fs::write(&launcher, launcher_script(role, &exe, &env)) {
if let Err(e) = write_atomic(&launcher, launcher_script(role, &exe, &env)) {
eprintln!("✗ 寫入 {} 失敗:{e}", launcher.display());
return 1;
}

View File

@ -40,7 +40,7 @@ fn scrub(v: &mut Value) {
pub fn export(b: &Bot) -> String {
let mut v = serde_json::to_value(b).unwrap_or(Value::Null);
if let Value::Object(m) = &mut v {
for k in ["messages", "allow_rules", "unread", "last_turn", "workdir", "pinned", "id"] {
for k in ["messages", "threads", "allow_rules", "unread", "last_turn", "workdir", "pinned", "id"] {
m.remove(k);
}
if let Some(Value::Array(routines)) = m.get_mut("routines") {

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

View File

@ -244,6 +244,40 @@ fn starts_with_words(cmd: &str, prefix: &str) -> bool {
!p.is_empty() && c.len() >= p.len() && c[..p.len()] == p[..]
}
/// The denied command hiding inside a wrapper: `(sudo …)`, `FOO=1 sudo …`, `env sudo …`,
/// `nohup sudo …` all run the same thing, so peel the wrappings off before matching.
fn denied_hit(part: &str) -> Option<&'static str> {
let mut toks: Vec<String> = Vec::new();
for w in words(part) {
let mut w = w;
while matches!(w.as_bytes().first(), Some(b'(') | Some(b'{')) {
w = &w[1..];
}
if w.is_empty() {
continue;
}
// VAR=val prefixes set the environment; they are not the command.
let is_assign = w.split_once('=').map(|(k, v)| !k.is_empty() && v.len() + 1 < w.len() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')).unwrap_or(false);
if toks.is_empty() && is_assign {
continue;
}
toks.push(w.to_string());
}
const WRAP: [&str; 7] = ["env", "command", "nohup", "time", "nice", "stdbuf", "timeout"];
while toks.first().map(|t| WRAP.contains(&t.as_str())).unwrap_or(false) {
toks.remove(0);
while toks.first().map(|t| t.starts_with('-')).unwrap_or(false) {
toks.remove(0);
}
// timeout takes a duration before the command.
if toks.first().map(|t| !t.is_empty() && t.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false)).unwrap_or(false) {
toks.remove(0);
}
}
let joined = toks.join(" ");
DENIED_COMMANDS.iter().find(|d| starts_with_words(&joined, d)).copied()
}
/// The "always allow" grant for a command: its first word, or first two for git/npm/cargo-style tools.
pub fn shell_grant(part: &str) -> String {
let w = words(part);
@ -296,7 +330,7 @@ pub fn decide(name: &str, args: &str, workdir: &Path, grants: &[String]) -> Deci
};
// deny
for part in &parts {
if let Some(d) = DENIED_COMMANDS.iter().find(|d| starts_with_words(part, d)) {
if let Some(d) = denied_hit(part) {
return Decision::Deny(format!("「{d}」這類指令一律不允許。"));
}
}
@ -614,10 +648,10 @@ fn ssh_command(target: &str, dir: &str, cmd: &str) -> String {
}
/// The local wrapper that runs `cmd` on a remote computer (an SSH host or the bot's box).
fn wrapper_for(place: &Place, cmd: &str, interactive: bool) -> Option<String> {
fn wrapper_for(place: &Place, cmd: &str, interactive: bool, job: &str) -> Option<String> {
match place {
Place::Ssh { target, dir } => Some(ssh_command(target, dir, cmd)),
Place::Box { dir } => Some(crate::cloudbox::exec_wrapper(dir, cmd, interactive)),
Place::Box { dir } => Some(crate::cloudbox::exec_wrapper(dir, cmd, interactive, job)),
_ => None,
}
}
@ -680,7 +714,7 @@ pub fn decide_on(name: &str, args: &str, target: &Target, grants: &[String]) ->
/// Run a remote command and give back its output (no background hand-off).
fn remote_capture(place: &Place, cmd: &str, stdin: Option<&[u8]>) -> Result<String, String> {
use std::io::Write;
let w = wrapper_for(place, cmd, stdin.is_some()).ok_or("不是遠端電腦")?;
let w = wrapper_for(place, cmd, stdin.is_some(), "").ok_or("不是遠端電腦")?;
let mut c = build_wrapper_command(&w, "", "");
let mut child = c.spawn().map_err(|e| format!("無法執行:{e}"))?;
if let Some(data) = stdin {
@ -732,6 +766,16 @@ pub fn run_on(name: &str, args: &str, target: &Target, cancel: &AtomicBool, ids:
if v.is_null() && !args.trim().is_empty() {
return ("參數不是有效的 JSON".into(), false);
}
// Box file tools stay inside this bot's own folder: every bot shares one container,
// one user and one disk, so `../` or an absolute path reached (and rewrote) the
// other bots' files — a planted .bashrc runs code on their next command.
let (p, path) = match place {
Place::Box { dir } if name != "shell" => match box_path(std::path::Path::new(dir), &p) {
Ok(s) => (s.clone(), s),
Err(e) => return (e, false),
},
_ => (p, path),
};
let remote = match name {
"shell" if arg_str(&v, "command").trim().is_empty() => return ("請給要執行的指令".into(), false),
"shell" => arg_str(&v, "command"),
@ -774,12 +818,45 @@ pub fn run_on(name: &str, args: &str, target: &Target, cancel: &AtomicBool, ids:
"read_terminal" | "write_terminal" => return run(name, args, Path::new("/"), cancel, ids),
other => return (format!("沒有「{other}」這個工具"), false),
};
let wrapper = wrapper_for(place, &remote, name == "shell").unwrap_or_default();
done(shell_wrapped(&wrapper, &remote, timeout_of(&v), cancel, ids))
// Tag the in-container command so stopping it here also stops it inside the box
// (killing the local docker client alone left the program running in the box).
let job = crate::httpd::random_hex(8);
let wrapper = wrapper_for(place, &remote, name == "shell", &job).unwrap_or_default();
let kill = matches!(place, Place::Box { .. }).then(|| crate::cloudbox::exec_kill_argv(&job));
done(shell_wrapped(&wrapper, &remote, timeout_of(&v), cancel, ids, kill))
}
}
}
/// Keep a Box file path inside the bot's own folder: relative paths join the bot's dir, and
/// a normalised result that lands outside it (`../`, absolute paths to other bots' folders)
/// is refused. All bots share one container and one user, so the folder is the only wall.
pub fn box_path(dir: &Path, p: &str) -> Result<String, String> {
let joined = if p.starts_with('/') { PathBuf::from(p) } else { dir.join(p) };
let mut parts: Vec<std::ffi::OsString> = vec![];
for c in joined.components() {
use std::path::Component::*;
match c {
ParentDir => {
parts.pop();
}
CurDir => {}
RootDir => parts.push(std::ffi::OsString::from("/")),
Normal(x) => parts.push(x.to_os_string()),
Prefix(x) => parts.push(x.as_os_str().to_os_string()),
}
}
let mut norm = PathBuf::new();
for x in parts {
norm.push(x);
}
if norm.starts_with(dir) {
Ok(norm.display().to_string())
} else {
Err(format!("這個檔案不在你的資料夾裡(Bot 的電腦上每個 Bot 只用自己的資料夾):{p}"))
}
}
/// Run a tool that the rules already allowed. Returns (output, ok).
pub fn run(name: &str, args: &str, workdir: &Path, cancel: &AtomicBool, ids: &RunIds) -> (String, bool) {
let v = args_of(args);
@ -977,15 +1054,15 @@ fn edit_file(p: &Path, old: &str, new: &str) -> Result<String, String> {
}
fn shell(cmd: &str, workdir: &Path, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, seal: Option<&Path>) -> Result<String, String> {
run_command(build_command_sealed(cmd, workdir, &protected_dirs(), &readable_dirs(), seal, &ids.conversation, &ids.request), cmd, timeout_ms, cancel, ids)
run_command(build_command_sealed(cmd, workdir, &protected_dirs(), &readable_dirs(), seal, &ids.conversation, &ids.request), cmd, timeout_ms, cancel, ids, None)
}
/// Run a wrapper command (ssh / docker exec) the same way: background hand-off, stop, output.
pub fn shell_wrapped(wrapper: &str, shown: &str, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds) -> Result<String, String> {
run_command(build_wrapper_command(wrapper, &ids.conversation, &ids.request), shown, timeout_ms, cancel, ids)
pub fn shell_wrapped(wrapper: &str, shown: &str, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, kill: Option<Vec<String>>) -> Result<String, String> {
run_command(build_wrapper_command(wrapper, &ids.conversation, &ids.request), shown, timeout_ms, cancel, ids, kill)
}
fn run_command(mut command: Command, cmd: &str, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds) -> Result<String, String> {
fn run_command(mut command: Command, cmd: &str, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, kill: Option<Vec<String>>) -> Result<String, String> {
let mut child = command.spawn().map_err(|e| format!("無法執行:{e}"))?;
let stdin = Arc::new(Mutex::new(child.stdin.take()));
let root = child.id();
@ -999,7 +1076,11 @@ fn run_command(mut command: Command, cmd: &str, timeout_ms: u64, cancel: &Atomic
if n == 0 {
break;
}
out.lock().unwrap().push_str(&String::from_utf8_lossy(&chunk[..n]));
// Cap the kept output (keep the newest): `yes` must not eat all the RAM.
let mut out = out.lock().unwrap();
if out.len() < 4_000_000 {
out.push_str(&String::from_utf8_lossy(&chunk[..n]));
}
}
});
}
@ -1014,6 +1095,14 @@ fn run_command(mut command: Command, cmd: &str, timeout_ms: u64, cancel: &Atomic
if cancel.load(Ordering::Relaxed) {
kill_tree(root);
let _ = child.kill();
// Reap it: a killed child nobody waits for stays a zombie for the app's lifetime.
let _ = child.wait();
// The remote end (a program running inside the box) is not in this process tree.
if let Some(k) = &kill {
if let Some((head, rest)) = k.split_first() {
let _ = Command::new(head).args(rest).status();
}
}
return Err("已停止(連同它啟動的程式)".into());
}
if start.elapsed() >= Duration::from_millis(timeout_ms) {
@ -1054,12 +1143,17 @@ fn read_terminal(id: u64) -> Result<String, String> {
/// Send input to a background command (like typing into its terminal). `close` ends its input.
fn write_terminal(id: u64, input: &str, close: bool) -> Result<String, String> {
use std::io::Write;
// Copy the terminal's own handles out first: writing to a pipe whose buffer is full blocks,
// and holding the shared registry lock while blocked stops every other terminal tool.
let (stdin, exit) = {
let t = terminals().lock().unwrap();
let term = t.get(&id).ok_or_else(|| format!("沒有背景指令 #{id}"))?;
if term.exit.lock().unwrap().is_some() {
(term.stdin.clone(), term.exit.clone())
};
if exit.lock().unwrap().is_some() {
return Err(format!("背景指令 #{id} 已經結束了"));
}
let mut slot = term.stdin.lock().unwrap();
let mut slot = stdin.lock().unwrap();
if !input.is_empty() {
let w = slot.as_mut().ok_or("這個指令的輸入已經關閉了")?;
w.write_all(input.as_bytes()).and_then(|_| w.flush()).map_err(|e| format!("送不進去:{e}"))?;
@ -1068,7 +1162,6 @@ fn write_terminal(id: u64, input: &str, close: bool) -> Result<String, String> {
*slot = None;
}
drop(slot);
drop(t);
std::thread::sleep(Duration::from_millis(400));
let out = read_terminal(id)?;
Ok(format!("已送出{}{}。\n{out}", if input.is_empty() { String::new() } else { format!(" {} 個字", input.chars().count()) }, if close { ",並結束輸入" } else { "" }))
@ -1077,9 +1170,12 @@ fn write_terminal(id: u64, input: &str, close: bool) -> Result<String, String> {
/// Stop every background command and what it started (when the app quits).
pub fn stop_all() {
for t in terminals().lock().unwrap().values() {
// child before exit — the watcher thread takes them in this order; the reverse order
// here could wedge both threads forever (AB-BA).
let mut child = t.child.lock().unwrap();
if t.exit.lock().unwrap().is_none() {
kill_tree(t.root);
let _ = t.child.lock().unwrap().kill();
let _ = child.kill();
}
}
}
@ -1088,9 +1184,11 @@ pub fn stop_all() {
pub fn stop_for(conversation: &str) -> usize {
let mut n = 0;
for t in terminals().lock().unwrap().values().filter(|t| t.conversation == conversation) {
// Same lock order as the watcher thread (child, then exit).
let mut child = t.child.lock().unwrap();
if t.exit.lock().unwrap().is_none() {
kill_tree(t.root);
let _ = t.child.lock().unwrap().kill();
let _ = child.kill();
n += 1;
}
}

View File

@ -315,9 +315,12 @@ pub fn github_runs(runs: &Value, seen: &mut HashSet<u64>, first: bool) -> Vec<Ev
/// status changes. Events are `created` and `<status>`.
pub fn status_changes(prev: &mut HashMap<String, String>, items: &[(String, String, String)], first: bool) -> Vec<(String, String)> {
let mut out = vec![];
// An empty prev is always the baseline: a poll that follows a failed first round must
// record, not fire every existing item as "created" (history replay).
let baseline = first || prev.is_empty();
for (id, status, title) in items {
match prev.get(id) {
None if !first => out.push(("created".to_string(), title.clone())),
None if !baseline => out.push(("created".to_string(), title.clone())),
Some(old) if old != status => out.push((status.clone(), format!("{title}({old} → {status})"))),
_ => {}
}
@ -437,6 +440,9 @@ fn sleep_alive(gen: u64, secs: u64) -> bool {
pub fn restart(watches: Vec<Watch>, slack_token: String, tx: Sender<Fired>, repaint: Arc<dyn Fn() + Send + Sync>) {
let gen = GENERATION.fetch_add(1, Ordering::SeqCst) + 1;
webhooks().lock().unwrap().clear();
// Fresh router too: old entries held senders of the previous generation (a leak, and a
// webhook could fire a routine that no longer exists).
*ROUTER_TX.lock().unwrap() = None;
for w in watches {
start_one(w.bot_id, w.routine_id, w.trigger, gen, slack_token.clone(), tx.clone(), repaint.clone());
}
@ -497,6 +503,9 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire:
let mut slack_own = String::new();
let mut slack_reacted: HashSet<String> = HashSet::new();
loop {
// first must only advance after a round that actually reached the service: a failed
// first round used to make the next one replay the whole history as new events.
let mut reached = true;
let wait = match &t {
Trigger::Github { repo, token, events, users, branch, pr } => {
let mut h = vec![("Accept", "application/vnd.github+json".to_string())];
@ -515,7 +524,7 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire:
cursor = next;
note(routine_id, "已連上 GitHub,等待事件");
}
Err(e) => note(routine_id, &format!("GitHub:{e}")),
Err(e) => { reached = false; note(routine_id, &format!("GitHub:{e}")) },
}
if wanted(events, "ci_passed") || wanted(events, "ci_failed") {
if let Ok(v) = get(&format!("https://api.github.com/repos/{repo}/actions/runs?per_page=20"), &h) {
@ -560,7 +569,7 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire:
note(routine_id, "已連上 Slack,等待訊息");
}
Ok(v) => note(routine_id, &format!("Slack 說:{}", v["error"].as_str().unwrap_or("不明錯誤"))),
Err(e) => note(routine_id, &format!("Slack:{e}")),
Err(e) => { reached = false; note(routine_id, &format!("Slack:{e}")) },
}
SLACK_POLL_SECS
}
@ -583,7 +592,7 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire:
}
note(routine_id, "已連上 Linear,等待變化");
}
Err(e) => note(routine_id, &format!("Linear:{e}")),
Err(e) => { reached = false; note(routine_id, &format!("Linear:{e}")) },
}
SERVICE_POLL_SECS
}
@ -620,7 +629,7 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire:
}
note(routine_id, "已連上 Sentry,等待變化");
}
Err(e) => note(routine_id, &format!("Sentry:{e}")),
Err(e) => { reached = false; note(routine_id, &format!("Sentry:{e}")) },
}
SERVICE_POLL_SECS
}
@ -642,7 +651,7 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire:
}
note(routine_id, "已連上 PagerDuty,等待事故");
}
Err(e) => note(routine_id, &format!("PagerDuty:{e}")),
Err(e) => { reached = false; note(routine_id, &format!("PagerDuty:{e}")) },
}
SERVICE_POLL_SECS
}
@ -663,6 +672,7 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire:
}
note(routine_id, "正在看「郵件」的收件匣");
} else {
reached = false;
note(routine_id, &o);
}
SERVICE_POLL_SECS
@ -679,13 +689,31 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire:
}
note(routine_id, "正在看「行事曆」");
} else {
reached = false;
note(routine_id, &o);
}
CALENDAR_POLL_SECS
}
Trigger::Webhook { .. } | Trigger::Group { .. } => return,
};
if reached {
first = false;
}
// These sets grow with every event seen: a watcher that runs for months must stay
// bounded (ids grow, so keep the newest range; the react/calendar sets are only
// short-term de-duplication, so a cap that clears is harmless).
if seen.len() > 4096 {
let m = *seen.iter().max().unwrap_or(&0);
seen.retain(|id| *id + 100_000 >= m);
}
if prev.len() > 4096 {
// status_changes treats an empty set as a quiet baseline (records, fires nothing),
// so clearing is safe and keeps the map from growing with every issue ever seen.
prev.clear();
}
if slack_reacted.len() > 4096 {
slack_reacted.clear();
}
if !sleep_alive(gen, wait) {
return;
}

View File

@ -238,6 +238,10 @@ fn keep(mut ws: WebSocket<Stream>, serial: u64, alive: &dyn Fn() -> bool) {
Ok(Message::Binary(b)) => {
heard = Instant::now();
if let Some((head, body)) = wire::unpack(&b) {
// The relay says this machine was unregistered (signed out): stop for good.
if head["t"] == "bye" {
break;
}
on_frame(head, body, &tx, &calls);
}
}

View File

@ -178,7 +178,10 @@ fn onepassword(host: &str) -> Option<Login> {
return None;
}
let items: Value = serde_json::from_slice(&list.stdout).ok()?;
let id = items.as_array()?.iter().find(|i| i["urls"].as_array().into_iter().flatten().any(|u| host_of(u["href"].as_str().unwrap_or("")).ends_with(host)))?["id"].as_str()?.to_string();
// Match the host exactly or as a subdomain (with the dot): "notgoogle.com" must not
// answer for "google.com" (a plain ends_with fills the wrong login on a lookalike domain).
let host_match = |h: &str, want: &str| h == want || h.ends_with(&format!(".{want}"));
let id = items.as_array()?.iter().find(|i| i["urls"].as_array().into_iter().flatten().any(|u| host_match(&host_of(u["href"].as_str().unwrap_or("")), host)))?["id"].as_str()?.to_string();
let got = Command::new("op").env("PATH", &path).args(["item", "get", &id, "--fields", "label=username,label=password", "--reveal", "--format", "json"]).output().ok()?;
let fields: Value = serde_json::from_slice(&got.stdout).ok()?;
let arr = fields.as_array()?;
@ -412,9 +415,6 @@ pub fn fill_login(plan: &LoginPlan, submit: bool) -> Result<String, String> {
let (username, password, source) = match (&plan.item, &plan.other) {
(Some(i), _) => {
let (u, p) = crate::vault_store::login_secret(&i.id).ok_or("保險庫裡沒有這筆登入資訊")?;
if !plan.matched {
crate::vault_store::add_site(&i.id, &plan.host)?;
}
(u, p, "保險庫")
}
(None, Some(l)) => (l.username.clone(), l.password.clone(), l.source),
@ -425,6 +425,13 @@ pub fn fill_login(plan: &LoginPlan, submit: bool) -> Result<String, String> {
if got["password"].as_bool() != Some(true) && got["user"].as_bool() != Some(true) {
return Err("這一頁找不到帳號或密碼欄位。先打開登入頁(有些網站要先按「登入」或先填帳號、按下一步才出現密碼欄)。".into());
}
// Remember the domain only after the fill worked: adding it on a failed (or lookalike)
// page made the next visit show matched:true and skip the anti-phishing warning.
if !plan.matched {
if let Some(i) = &plan.item {
crate::vault_store::add_site(&i.id, &plan.host)?;
}
}
Ok(format!(
"已在 {} 填入{}{}(來自{source},你看不到密碼)。{}",
plan.host,

View File

@ -381,8 +381,12 @@ fn store_key(key: &[u8]) -> Result<(), String> {
fn key(create: bool) -> Result<aead::LessSafeKey, String> {
let k = match read_key() {
Some(k) if k.len() == 32 => k,
_ if !create => return Err("找不到保險庫的金鑰(系統鑰匙圈裡的「AnyBot vault key」)".into()),
_ => {
// An entry exists but is not a key: generating a fresh one (and -U overwriting the
// entry) would destroy the only copy of the old key — every saved password would be
// undecryptable forever. Stop and tell the person instead.
Some(_) => return Err("保險庫金鑰損毀:鑰匙圈裡的「AnyBot vault key」內容不正確。已停止動作,避免覆蓋後舊密碼永遠解不開;若你確定不需要舊密碼,可手動刪除該鑰匙圈項目再試。".into()),
None if !create => return Err("找不到保險庫的金鑰(系統鑰匙圈裡的「AnyBot vault key」)".into()),
None => {
let mut k = vec![0u8; 32];
SystemRandom::new().fill(&mut k).map_err(|_| "產生金鑰失敗")?;
store_key(&k)?;

View File

@ -163,7 +163,7 @@ impl Recording {
}
match said.trim() {
"denied" => {
let _ = Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone").spawn();
let _ = crate::computer::spawn_reaped(&mut Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"));
Err("macOS 還沒讓 AnyBot 使用麥克風。我已經打開「系統設定 → 隱私權與安全性 → 麥克風」:把 AnyBot(或「終端機」)打開,再試一次。".into())
}
"fail" => Err("找不到可以用的麥克風。".into()),

View File

@ -61,17 +61,25 @@ pub fn read_needs_permission(args: &str) -> bool {
/// The browser tools, also when the browser is on another computer of the account (裝置連結):
/// then each takes a `computer`, since each computer's AnyBot browser has its own logins.
pub fn specs_for(devices: &[String]) -> Vec<Value> {
pub fn specs_for(here: &str, devices: &[String]) -> Vec<Value> {
let local = crate::botbrowser::available();
let mut v = specs_inner(local || !devices.is_empty());
if !devices.is_empty() {
let mut names: Vec<String> = devices.to_vec();
// A brain on a server with the person's computer as its hands: that one's browser first.
let person = devices.iter().any(|d| d == crate::model::THIS_COMPUTER_NAME);
if local {
names.insert(0, crate::model::THIS_COMPUTER_NAME.to_string());
names.insert(if person { names.len() } else { 0 }, here.to_string());
}
let desc = format!(
"Which computer's AnyBot browser to use. Each computer's browser has its own logins (a site logged in on one isn't on another). {}",
if local { format!("Leave out for {}.", crate::model::THIS_COMPUTER_NAME) } else { "This computer has no browser: always name one.".to_string() }
if person {
format!("Leave out for {} (the person's own computer, where they can see the window).", crate::model::THIS_COMPUTER_NAME)
} else if local {
format!("Leave out for {here}.")
} else {
"This computer has no browser: always name one.".to_string()
}
);
for t in v.iter_mut().filter(|t| t["name"].as_str().map(|n| n.starts_with("browser_")).unwrap_or(false)) {
t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": names, "description": desc});
@ -170,7 +178,9 @@ pub fn normalize_url(u: &str) -> Result<String, String> {
return Err("請給網址".into());
}
let full = if u.starts_with("http://") || u.starts_with("https://") { u.to_string() } else { format!("https://{u}") };
if full.contains(char::is_whitespace) || !full[8..].contains('.') && !full.contains("localhost") {
// Check the host part after the scheme (a fixed [8..] cut one byte short for http://).
let rest = full.split_once("://").map(|(_, r)| r).unwrap_or(&full);
if full.contains(char::is_whitespace) || !rest.contains('.') && !rest.starts_with("localhost") {
return Err(format!("「{u}」看起來不是網址"));
}
Ok(full)

View File

@ -92,8 +92,29 @@ static WL: Mutex<Option<Wl>> = Mutex::new(None);
/// The four modifiers always sit on the first keys, with their xkb modifier bits.
const MODS: [(u32, u32, &str); 4] = [(0xffe1, 1, "Shift"), (0xffe3, 4, "Control"), (0xffe9, 8, "Mod1"), (0xffeb, 64, "Mod4")];
/// Ask the kernel to give up a read on the compositor socket after `t`. A compositor that
/// stops answering used to block `blocking_dispatch` forever while the app's global Wayland
/// lock is held — every screen action (and the UI on top of it) froze. The timed-out read
/// arrives as an error, so the connection is treated as dead and rebuilt on the next call.
fn set_read_timeout(fd: std::os::fd::RawFd, t: Duration) -> std::io::Result<()> {
let tv = libc::timeval { tv_sec: i64::try_from(t.as_secs()).unwrap_or(i64::MAX), tv_usec: 0 };
let rc = unsafe {
libc::setsockopt(fd, libc::SOL_SOCKET, libc::SO_RCVTIMEO, &tv as *const _ as *const libc::c_void, std::mem::size_of::<libc::timeval>() as libc::socklen_t)
};
if rc == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
fn connect() -> Option<Wl> {
let conn = Connection::connect_to_env().ok()?;
// The compositor's socket: let the kernel give up on reads after 60 s (see
// set_read_timeout). Timed-out reads arrive as errors, so the connection is treated
// as dead and rebuilt on the next call.
use std::os::fd::AsRawFd as _;
set_read_timeout(conn.as_fd().as_raw_fd(), Duration::from_secs(60)).ok()?;
let (globals, mut queue) = registry_queue_init::<State>(&conn).ok()?;
let qh = queue.handle();
let shm: wl_shm::WlShm = globals.bind(&qh, 1..=1, ()).ok()?;
@ -230,20 +251,24 @@ impl Wl {
fn capture_output(&mut self, out: &wl_output::WlOutput) -> Option<image::RgbaImage> {
self.st.frame = Frame::default();
let frame = self.copy.capture_output(0, out, &self.qh, ());
// Every early exit passes through here, so the frame (and its buffer/pool) are always
// destroyed: failure paths used to leak compositor-side objects on every failed grab.
let r = self.grab(&frame);
frame.destroy();
r
}
fn grab(&mut self, frame: &frame_v1::ZwlrScreencopyFrameV1) -> Option<image::RgbaImage> {
let deadline = Instant::now() + Duration::from_secs(5);
while !(self.st.frame.failed || (self.st.frame.buffer.is_some() && (self.st.frame.buffer_done || self.copy_version < 3))) {
if Instant::now() > deadline {
frame.destroy();
return None;
}
self.queue.blocking_dispatch(&mut self.st).ok()?;
}
let (format, w, h, stride) = self.st.frame.buffer?;
let WEnum::Value(fmt) = format else {
frame.destroy();
return None;
};
let size = (stride * h) as u64;
let WEnum::Value(fmt) = format else { return None };
let size = (stride as u64) * (h as u64);
let file = scratch_file(size).ok()?;
let pool = self.shm.create_pool(file.as_fd(), size as i32, &self.qh, ());
let buffer = pool.create_buffer(0, w as i32, h as i32, stride as i32, fmt, &self.qh, ());
@ -256,7 +281,6 @@ impl Wl {
}
let ok = self.st.frame.ready;
let y_invert = self.st.frame.y_invert;
frame.destroy();
buffer.destroy();
pool.destroy();
if !ok {
@ -295,10 +319,12 @@ pub fn to_rgba(data: &[u8], fmt: wl_shm::Format, w: u32, h: u32, stride: u32, y_
wl_shm::Format::Xbgr8888 | wl_shm::Format::Abgr8888 => false,
_ => return None,
};
let mut out = Vec::with_capacity((w * h * 4) as usize);
// Byte maths in usize: extreme resolutions overflow a u32 product (debug builds panic).
let mut out = Vec::with_capacity((w as usize) * (h as usize) * 4);
for row in 0..h {
let r = if y_invert { h - 1 - row } else { row };
let line = data.get((r * stride) as usize..(r * stride + w * 4) as usize)?;
let (a, b) = ((r as u64 * stride as u64) as usize, ((r as u64 * stride as u64) as usize) + (w as usize) * 4);
let line = data.get(a..b)?;
for px in line.chunks_exact(4) {
if bgr {
out.extend_from_slice(&[px[2], px[1], px[0], 255]);
@ -425,16 +451,25 @@ pub fn idle_secs() -> Option<f64> {
static STARTED: std::sync::Once = std::sync::Once::new();
STARTED.call_once(|| {
std::thread::spawn(|| {
// Keep listening for the app's whole life: when the connection drops (compositor
// restart, read timeout) reconnect and listen again — the old loop quit forever
// after one break and idle_secs() went blind.
loop {
let Ok(conn) = Connection::connect_to_env() else { return };
let Ok((globals, mut queue)) = registry_queue_init::<IdleState>(&conn) else { return };
let qh = queue.handle();
let Ok(seat) = globals.bind::<wl_seat::WlSeat, _, _>(&qh, 1..=1, ()) else { return };
let Ok(notifier) = globals.bind::<ext_idle_notifier_v1::ExtIdleNotifierV1, _, _>(&qh, 1..=1, ()) else { return };
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);
let mut st = IdleState;
while queue.blocking_dispatch(&mut st).is_ok() {}
*IDLE.lock().unwrap_or_else(|e| e.into_inner()) = None;
std::thread::sleep(Duration::from_secs(2));
}
});
});
None

View File

@ -53,16 +53,16 @@ pub fn capture() -> Result<image::RgbImage, String> {
return None;
}
let lsb = setup.image_byte_order == xproto::ImageOrder::LSB_FIRST;
let (w, h) = (x.width as u32, x.height as u32);
let mut rgb = Vec::with_capacity((w * h * 3) as usize);
for px in reply.data.chunks_exact(4).take((w * h) as usize) {
let (w, h) = (x.width as usize, x.height as usize);
let mut rgb = Vec::with_capacity(w * h * 3);
for px in reply.data.chunks_exact(4).take(w * h) {
if lsb {
rgb.extend_from_slice(&[px[2], px[1], px[0]]);
} else {
rgb.extend_from_slice(&[px[1], px[2], px[3]]);
}
}
image::RgbImage::from_raw(w, h, rgb)
image::RgbImage::from_raw(w as u32, h as u32, rgb)
})
.ok_or_else(|| "讀不到螢幕畫面(需要 X11 桌面;Wayland 請在登入畫面選「Xorg」工作階段)".to_string())
}
@ -302,7 +302,11 @@ pub fn type_text(text: &str) {
for c in text.chars() {
let sym = keysym_for_char(c);
let syms = vec![sym; per];
x.conn.change_keyboard_mapping(1, spare, per as u8, &syms).ok()?;
// On any failure stop the loop and fall through to restoring the mapping: an
// early `?` here left the typed character glued to a real key forever.
if x.conn.change_keyboard_mapping(1, spare, per as u8, &syms).is_err() {
break;
}
flush(x);
std::thread::sleep(Duration::from_millis(12));
fake(x, xproto::KEY_PRESS_EVENT, spare, 0, 0);
@ -428,11 +432,13 @@ pub fn record(mut each: impl FnMut(Input), keep_going: impl Fn() -> bool + Send
_ => 0,
}
};
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let stopper = std::thread::spawn({
let ctx = ctx;
let stop = stop.clone();
move || {
// Disabling the context ends the data stream below.
while keep_going() {
while keep_going() && !stop.load(std::sync::atomic::Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(150));
}
let _ = ctrl.record_disable_context(ctx);
@ -442,7 +448,16 @@ pub fn record(mut each: impl FnMut(Input), keep_going: impl Fn() -> bool + Send
let _ = ctrl.flush();
}
});
let stream = data.record_enable_context(ctx).map_err(|e| e.to_string())?;
let stream = match data.record_enable_context(ctx) {
Ok(s) => s,
// A failed start must stop the watchdog thread too — it used to spin forever
// (holding an X connection) on this early return.
Err(e) => {
stop.store(true, std::sync::atomic::Ordering::Relaxed);
let _ = stopper.join();
return Err(e.to_string());
}
};
for reply in stream {
let Ok(reply) = reply else { break };
if reply.category != 0 {
@ -482,6 +497,9 @@ pub fn record(mut each: impl FnMut(Input), keep_going: impl Fn() -> bool + Send
}
}
}
// The stream broke (the X server died, the context was dropped): tell the watchdog to
// stop first — waiting on a thread that keeps polling forever is what froze the old code.
stop.store(true, std::sync::atomic::Ordering::Relaxed);
let _ = stopper.join();
Ok(())
}