diff --git a/Cargo.lock b/Cargo.lock index db37e28..aced7ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -351,6 +351,7 @@ dependencies = [ "hayro", "iana-time-zone", "image", + "libc", "memmap2", "mermaid-rs-renderer", "pdf-extract", diff --git a/Cargo.toml b/Cargo.toml index 10dba24..ea98312 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/Makefile b/Makefile index e40ee34..97bfa9c 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,10 @@ export PATH := $(HOME)/.cargo/bin:$(PATH) CARGO ?= cargo UNAME := $(shell uname -s) -VERSION ?= $(shell git describe --tags --always 2>/dev/null | sed 's/^v//' || echo dev) +# `git describe` prints nothing outside a repo; sed passes the empty string through with +# status 0, so "|| echo dev" never fired and VERSION came out blank ("AnyBot ", dist files +# named ".tar.gz"). grep . fails on empty input, which makes the fallback work. +VERSION ?= $(shell git describe --tags --always 2>/dev/null | sed 's/^v//' | grep . || echo dev) TARGET := $(or $(CARGO_TARGET_DIR),target) APPDIR ?= $(HOME)/Applications BIN := $(TARGET)/release/anybot @@ -45,7 +48,7 @@ build: app relay check-rust: @command -v $(CARGO) >/dev/null 2>&1 || { echo; echo " ✗ 找不到 Rust(cargo)。先執行:make deps"; echo; exit 1; } - @v=$$(rustc --version | sed -n 's/^rustc 1\.\([0-9]*\).*/\1/p'); [ "$${v:-0}" -ge 95 ] || { echo; echo " ✗ Rust 太舊($$(rustc --version)),要 1.95 以上。執行:rustup update stable(或 make deps)"; echo; exit 1; } + @set -- $$(rustc --version | sed -n 's/^rustc \([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1 \2/p'); { [ "$${1:-0}" -gt 1 ] || { [ "$${1:-0}" -eq 1 ] && [ "$${2:-0}" -ge 95 ]; }; } || { echo; echo " ✗ Rust 太舊($$(rustc --version)),要 1.95 以上。執行:rustup update stable(或 make deps)"; echo; exit 1; } app: check-rust $(CARGO) build --release --bin anybot diff --git a/box/start.sh b/box/start.sh index cfb662e..7e16c5a 100644 --- a/box/start.sh +++ b/box/start.sh @@ -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' -x11vnc -display :1 -forever -shared -nopw -quiet -rfbport 5900 -localhost >/tmp/x11vnc.log 2>&1 & +# VNC login key: the app starts the box with VNC_PASSWORD and opens the viewer with it. +# Without it (an older app) the desktop stays keyless as before. +if [ -n "${VNC_PASSWORD:-}" ]; then + mkdir -p /home/bot/.vnc + printf '%s' "$VNC_PASSWORD" | x11vnc -storepasswd /home/bot/.vnc/pass >/dev/null 2>&1 + x11vnc -display :1 -forever -shared -quiet -rfbauth /home/bot/.vnc/pass -rfbport 5900 -localhost >/tmp/x11vnc.log 2>&1 & +else + x11vnc -display :1 -forever -shared -nopw -quiet -rfbport 5900 -localhost >/tmp/x11vnc.log 2>&1 & +fi websockify --web /usr/share/novnc 6080 localhost:5900 >/tmp/novnc.log 2>&1 & -echo "anybot-box ready" > /tmp/anybot-box.ready -wait +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 +) & +# 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 diff --git a/install.sh b/install.sh index cff4f5c..7a58d2f 100755 --- a/install.sh +++ b/install.sh @@ -23,7 +23,16 @@ MODE=${1:-auto} [ $# -gt 0 ] && shift case $MODE in app|hands|headless|server|deps|auto) ;; - -h|--help|help) sed -n '2,14p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//'; exit 0 ;; + -h|--help|help) + # 用 curl | sh 執行時 $0 是「sh」,讀不到腳本原文:改印內嵌的說明。 + usage=$(sed -n '2,14p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//' || true) + [ -n "$usage" ] || usage='AnyBot 一鍵安裝: + sh install.sh 桌面 App(Mac、有螢幕的 Linux) + sh install.sh hands [網址 配對碼] 沒有螢幕的 Linux,接成大腦的手腳 + sh install.sh server 伺服器:帳號伺服器 + 大腦 + HTTPS(Docker) + sh install.sh deps 只裝建置需要的東西(之後自己 make) +環境變數:ANYBOT_DIR(原始碼位置,預設 ~/AnyBot)、ANYBOT_BRANCH(預設 main)。' + printf '%s\n' "$usage"; exit 0 ;; *) echo "不認得「${MODE}」。可以用:app、hands、server、deps"; exit 1 ;; esac [ "$MODE" = headless ] && MODE=hands @@ -43,6 +52,9 @@ have() { command -v "$1" >/dev/null 2>&1; } TTY=/dev/tty [ -r "$TTY" ] && [ -w "$TTY" ] && (exec <"$TTY") 2>/dev/null || TTY= +# PATH 提示要看的是使用者登入後的 PATH,不是腳本自己改過的(下面會加 ~/.local/bin)。 +START_PATH=$PATH + # ---------- 這台是什麼 ---------- OS=$(uname -s); ARCH=$(uname -m) case $OS in @@ -79,6 +91,8 @@ printf '\n %s◆ AnyBot 安裝%s %s%s · %s %s%s\n' "$C$B" "$N" "$D" "$TITLE" # ---------- 系統套件 ---------- # apt:只裝這個發行版有的名字(例如 Ubuntu 24.04 的 libasound2 叫 libasound2t64)。 +# 最後加 true:清單最後一個套件不存在時,最後一条 apt-cache 判斷會失敗, +# 在 set -e 下整個腳本會無訊息退出(呼叫端是 pkgs=$(apt_pick …))。 apt_pick() { for p in "$@"; do case $p in @@ -89,6 +103,16 @@ apt_pick() { *) apt-cache show "$p" >/dev/null 2>&1 && echo "$p" ;; esac done + true +} + +# zypper:同 apt_pick,裝之前先過濾掉這台找不到的名字(SUSE 各版本套件名差異大, +# 一個名字不存在會讓整組安裝失敗)。 +zypper_pick() { + for p in "$@"; do + zypper --non-interactive info "$p" >/dev/null 2>&1 && echo "$p" + done + true } linux_packages() { @@ -137,8 +161,14 @@ linux_packages() { ;; zypper) base="gcc gcc-c++ make pkg-config git curl alsa-devel" + extra="" + # app/hands 也要有這台该有的套件(之前只有 base,桌面模式缺 xkbcommon/GL/通知…, + # 編譯或執行時才炸)。名字逐個過濾,找不到的跳過。 + [ "$1" = app ] && extra="libxkbcommon-x11-0 Mesa libGL1 xdg-utils libnotify-tools libnotify4 libsecret-1-0 espeak zenity xclip wl-clipboard ffmpeg fonts-noto" + [ "$1" = hands ] && extra="xorg-x11-server-Xvfb openbox at-spi2-core at-spi2-atk-common dbus-1-x11 xauth fonts-noto" [ "$1" = server ] && base="curl git" - $SUDO zypper -n install $base || die "安裝系統套件失敗。" "sudo zypper install $base" + pkgs=$(zypper_pick $base $extra) + $SUDO zypper -n install $pkgs || die "安裝系統套件失敗。" "sudo zypper install $pkgs" ;; *) die "認不出這台的套件管理程式(apt、dnf、pacman、zypper 都沒有)。" \ @@ -157,8 +187,11 @@ mac_tools() { # ---------- Rust ---------- rust_ok() { have cargo && have rustc || return 1 - minor=$(rustc --version 2>/dev/null | sed -n 's/^rustc 1\.\([0-9]*\).*/\1/p') - [ -n "$minor" ] && [ "$minor" -ge $RUST_MIN ] + # 抓「主版號 次版號」:舊寫法死鎖在 1.x,rustc 2.x 會被当成「太舊」。 + set -- $(rustc --version 2>/dev/null | sed -n 's/^rustc \([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1 \2/p') + [ $# -ge 2 ] || return 1 + [ "$1" -gt 1 ] && return 0 + [ "$1" -eq 1 ] && [ "$2" -ge "$RUST_MIN" ] } ensure_rust() { [ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env" @@ -166,6 +199,8 @@ ensure_rust() { if have rustup; then info "Rust 太舊,更新中(rustup update stable)…" rustup update stable >/dev/null 2>&1 && rustup default stable >/dev/null 2>&1 || true + # rustup 裝在 ~/.cargo/bin;這個終端機可能還沒有它(--no-modify-path 裝的)。 + [ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env" else if have rustc; then warn "系統的 Rust $(rustc --version | cut -d' ' -f2) 太舊(要 1.$RUST_MIN 以上),另外用 rustup 裝新版"; fi info "安裝 Rust(rustup,裝在 ~/.cargo,不用 root)…" @@ -326,6 +361,6 @@ case $MODE in if ! have docker; then info "Bot 自己的電腦(一個隔離的桌面)需要 Docker,要用時 AnyBot 會告訴你怎麼裝。"; fi info "要更新:再執行一次同一行指令。" fi - case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) [ $PLAT = linux ] && info "(終端機找不到 anybot 的話,重新登入,或把 ~/.local/bin 加進 PATH)" ;; esac + case ":$START_PATH:" in *":$HOME/.local/bin:"*) ;; *) [ $PLAT = linux ] && info "(終端機找不到 anybot 的話,重新登入,或把 ~/.local/bin 加進 PATH)" ;; esac ;; esac diff --git a/mobile/index.html b/mobile/index.html index 983d661..d799b4d 100644 --- a/mobile/index.html +++ b/mobile/index.html @@ -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(); } diff --git a/relay/src/main.rs b/relay/src/main.rs index d2191b9..3abb94e 100644 --- a/relay/src/main.rs +++ b/relay/src/main.rs @@ -36,6 +36,9 @@ const ICON: &str = r##" = 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,10 +433,17 @@ 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")) { - 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) }); - } + // 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" => { @@ -620,10 +633,16 @@ impl Relay { } } ("POST", ["relay", "host", "reply", id]) => { - if self.host(&req).is_none() { - return Resp::error(401, "這台電腦的金鑰無效"); - } - if let Some(tx) = self.waiting.lock().unwrap().remove(*id) { + 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) = 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::>()})) } + // 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::(); + 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); + } } diff --git a/src/agent.rs b/src/agent.rs index 6409e30..094707c 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -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 { }) } +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 = hay.chars().collect(); + let nb: Vec = 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::>().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 { let v = tools::args_of(args); @@ -235,8 +331,11 @@ fn maybe_compact(job: &Job, history: &mut Vec, 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![]; diff --git a/src/app.rs b/src/app.rs index 1da9e00..2391d59 100644 --- a/src/app.rs +++ b/src/app.rs @@ -690,14 +690,15 @@ impl App { let mut tool_specs = appctl::specs(); tool_specs.extend(tools::specs_for(&names, &default_computer)); let devices: Vec = 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::>()); + // 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::>() + .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)); } } - self.start_reply(ctx, id); + 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::()) } 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\ diff --git a/src/app/files_ui.rs b/src/app/files_ui.rs index c2497d3..e980181 100644 --- a/src/app/files_ui.rs +++ b/src/app/files_ui.rs @@ -96,6 +96,18 @@ fn start_job(kind: &str, path: &str) -> Option>> { 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,8 +270,30 @@ impl App { }); } Preview::Image => { + // The picture is decoded on a worker thread (start_job "image"). if fv.image.is_none() { - fv.image = load_texture(ui.ctx(), &fv.path); + 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) => { diff --git a/src/app/markdown_ui.rs b/src/app/markdown_ui.rs index b64b322..8422933 100644 --- a/src/app/markdown_ui.rs +++ b/src/app/markdown_ui.rs @@ -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(); diff --git a/src/app/remote_ui.rs b/src/app/remote_ui.rs index bdf71d7..4c2c7a0 100644 --- a/src/app/remote_ui.rs +++ b/src/app/remote_ui.rs @@ -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 = 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() } diff --git a/src/app/vault_ui.rs b/src/app/vault_ui.rs index 5cc1c3f..ed13360 100644 --- a/src/app/vault_ui.rs +++ b/src/app/vault_ui.rs @@ -587,8 +587,11 @@ 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; } - saved = true; } } self.vault.dialog = next; diff --git a/src/appctl.rs b/src/appctl.rs index 00056fe..5d5a1d8 100644 --- a/src/appctl.rs +++ b/src/appctl.rs @@ -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), }; - out.theme_changed = true; - changed.push(format!("主題 → {t}")); + 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 }; diff --git a/src/axread.rs b/src/axread.rs index e737e9c..a794bfa 100644 --- a/src/axread.rs +++ b/src/axread.rs @@ -304,7 +304,7 @@ pub fn read_person_browser(exe: &str, max_chars: usize) -> Result 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(); diff --git a/src/botbrowser.rs b/src/botbrowser.rs index 3c78256..d5a7115 100644 --- a/src/botbrowser.rs +++ b/src/botbrowser.rs @@ -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 { /// Which profile a tool call means: the one named, or the current one. pub fn resolve(requested: Option<&str>) -> Result { + 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 = const { std::cell::Cell::new(false) }; +} + +/// Run `f` with the browser steps on Bot 的電腦's own Chromium. +pub fn in_box(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()); diff --git a/src/channels.rs b/src/channels.rs index d133a48..f78a594 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -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 { 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(&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::())); @@ -457,8 +466,9 @@ fn poll(c: Channel, gen: u64, tx: Sender, repaint: Arc { 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, repaint: Arc 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, repaint: Arc 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, repaint: Arc 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, repaint: Arc { - // 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, repaint: Arc { 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 })); } } } diff --git a/src/cloudbox.rs b/src/cloudbox.rs index cccb5bd..d512d50 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -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 { 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 { - 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, 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 { + static PORT: Mutex = 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 { + 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 { .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 { } 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 { + 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 { 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::()) + .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), 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| -> Result { + // 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), 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::>()?; if path.len() < 2 { return Err("拖曳至少要兩個點(每個點要有數字 x 和 y,用截圖上的座標)".into()); } @@ -1307,7 +1447,8 @@ pub fn viewer_url(control: bool) -> Result { } 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")); diff --git a/src/computer.rs b/src/computer.rs index 9fce0fc..8dbbd35 100644 --- a/src/computer.rs +++ b/src/computer.rs @@ -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> = 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) { /// Same, remembering which bot acted (for the 「正在操作你的電腦」 bar). pub fn run_by(name: &str, args: &str, who: &str) -> (String, bool, Option) { 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> 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> } "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::>()?; if path.len() < 2 { return Err("拖曳至少要兩個點(每個點要有數字 x 和 y,用截圖上的座標)".into()); } @@ -559,7 +572,7 @@ pub fn target_app(name: &str, args: &str) -> Option { 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 { 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 { 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); diff --git a/src/desk.rs b/src/desk.rs index c0113ee..246184b 100644 --- a/src/desk.rs +++ b/src/desk.rs @@ -308,7 +308,11 @@ pub fn screensaver_on() -> bool { if !wayland() { return x11::screensaver_on(); } + // GNOME speaks org.gnome.ScreenSaver.GetActive at /org/gnome/ScreenSaver; the plain + // freedesktop one (KDE & co.) is org.freedesktop.ScreenSaver.IsActive at /ScreenSaver — + // the old fallback asked for a path and method that never exist, so on KDE it always + // answered "screen is free". dbus_call::("org.gnome.ScreenSaver", "/org/gnome/ScreenSaver", "org.gnome.ScreenSaver", "GetActive") - .or_else(|| dbus_call::("org.freedesktop.ScreenSaver", "/org/freedesktop/ScreenSaver", "org.freedesktop.ScreenSaver", "GetActive")) + .or_else(|| dbus_call::("org.freedesktop.ScreenSaver", "/ScreenSaver", "org.freedesktop.ScreenSaver", "IsActive")) .unwrap_or(false) } diff --git a/src/egress.rs b/src/egress.rs index d261100..7c110e1 100644 --- a/src/egress.rs +++ b/src/egress.rs @@ -43,11 +43,12 @@ pub fn start() -> Result { 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 = 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())); } diff --git a/src/httpd.rs b/src/httpd.rs index 461b3e9..eac8782 100644 --- a/src/httpd.rs +++ b/src/httpd.rs @@ -90,23 +90,34 @@ 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 { + 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'%'); - } + 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; } - c => out.push(c), } - i += 1; } String::from_utf8_lossy(&out).to_string() } @@ -143,19 +154,61 @@ pub fn read_request(c: &mut TcpStream) -> Result { let method = fp.next().unwrap_or("").to_string(); let target = fp.next().unwrap_or("/").to_string(); let headers: HashMap = lines.filter_map(|l| l.split_once(':')).map(|(k, v)| (k.trim().to_ascii_lowercase(), v.trim().to_string())).collect(); - let len: usize = headers.get("content-length").and_then(|v| v.parse().ok()).unwrap_or(0); - if len > MAX_BODY { - return Err(413); - } + 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(); - while body.len() < len { - let n = c.read(&mut buf).map_err(|_| 400u16)?; - if n == 0 { - break; + 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 = 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.extend_from_slice(&buf[..n]); + 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); + } + while body.len() < len { + let n = c.read(&mut buf).map_err(|_| 400u16)?; + if n == 0 { + break; + } + body.extend_from_slice(&buf[..n]); + } + body.truncate(len); } - 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() }) } diff --git a/src/i18n.rs b/src/i18n.rs index f736652..a4e0ebf 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -55,8 +55,22 @@ fn current() -> &'static RwLock { 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"); diff --git a/src/icons.rs b/src/icons.rs index 4a5a2a9..153cddf 100644 --- a/src/icons.rs +++ b/src/icons.rs @@ -250,13 +250,19 @@ fn rasterize(icon: Icon, px: u32) -> Option { 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 diff --git a/src/macapps.rs b/src/macapps.rs index 80c717f..1c4f676 100644 --- a/src/macapps.rs +++ b/src/macapps.rs @@ -185,7 +185,7 @@ fn jxa(script: &str, args: &Value) -> Result { 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)) diff --git a/src/mcp.rs b/src/mcp.rs index 3fd4f9d..7bed146 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -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, session: Option }, } -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, tools: Vec, config: Server, } -fn conns() -> &'static Mutex> { - static C: OnceLock>> = OnceLock::new(); +/// One lock for the table, one lock per server's link. +fn conns() -> &'static Mutex>> { + static C: OnceLock>>> = OnceLock::new(); C.get_or_init(|| Mutex::new(HashMap::new())) } @@ -141,7 +148,7 @@ pub fn parse_http_answer(body: &str, id: u64) -> Result { Err("MCP 伺服器沒有給答案".into()) } -impl Conn { +impl Live { fn request(&mut self, method: &str, params: Value) -> Result { let id = self.next_id; self.next_id += 1; @@ -224,13 +231,13 @@ fn connect(s: &Server) -> Result { }); 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 = 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 { 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 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 { - let _ = child.kill(); + let mut to_connect: Vec = 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.live.lock().unwrap().link { + let _ = child.kill(); + } + } + keep + }); + for s in wanted { + if !map.contains_key(&s.name) { + to_connect.push(s.clone()); } } - keep - }); - for s in wanted { - if map.contains_key(&s.name) { - continue; - } - 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) { 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) { } 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(); } } diff --git a/src/mic.rs b/src/mic.rs index 012b794..e621310 100644 --- a/src/mic.rs +++ b/src/mic.rs @@ -60,6 +60,18 @@ pub fn db(samples: &[f32]) -> f64 { if rms <= 1e-8 { -160.0 } else { 20.0 * rms.log10() } } +/// Append the mono mix of `data` to the buffer. Runs inside cpal's real-time callback, so it +/// only takes the lock with `try_lock` (blocking there starves the audio device → xruns and +/// crackly audio; the reader sleeps 100 ms between looks, so a skipped block is rare and +/// harmless) and does no per-sample allocation. +fn mix(data: &[T], channels: usize, buf: &Arc>>, to_f32: impl Fn(T) -> f32) { + let Ok(mut g) = buf.try_lock() else { return }; + g.reserve(data.len() / channels.max(1)); + for frame in data.chunks(channels) { + g.push(frame.iter().map(|s| to_f32(*s)).sum::() / frame.len() as f32); + } +} + fn run(file: PathBuf, listen: Option, max_secs: u64, stop: Arc) -> &'static str { use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; let host = cpal::default_host(); @@ -69,27 +81,12 @@ fn run(file: PathBuf, listen: Option, max_secs: u64, stop: Arc>> = Arc::new(Mutex::new(Vec::new())); let b = buf.clone(); - let mono = move |data: &[f32]| { - let mut g = b.lock().unwrap(); - for frame in data.chunks(channels) { - g.push(frame.iter().sum::() / frame.len() as f32); - } - }; let err = |_| {}; let stream = match cfg.sample_format() { - cpal::SampleFormat::F32 => device.build_input_stream(cfg.config(), move |d: &[f32], _: &_| mono(d), err, None), - cpal::SampleFormat::I16 => { - let m = mono; - device.build_input_stream(cfg.config(), move |d: &[i16], _: &_| m(&d.iter().map(|s| *s as f32 / 32768.0).collect::>()), err, None) - } - cpal::SampleFormat::I32 => { - let m = mono; - device.build_input_stream(cfg.config(), move |d: &[i32], _: &_| m(&d.iter().map(|s| *s as f32 / 2_147_483_648.0).collect::>()), err, None) - } - cpal::SampleFormat::U8 => { - let m = mono; - device.build_input_stream(cfg.config(), move |d: &[u8], _: &_| m(&d.iter().map(|s| (*s as f32 - 128.0) / 128.0).collect::>()), err, None) - } + cpal::SampleFormat::F32 => device.build_input_stream(cfg.config(), move |d: &[f32], _: &_| mix(d, channels, &b, |x| x), err, None), + cpal::SampleFormat::I16 => device.build_input_stream(cfg.config(), move |d: &[i16], _: &_| mix(d, channels, &b, |x| x as f32 / 32768.0), err, None), + cpal::SampleFormat::I32 => device.build_input_stream(cfg.config(), move |d: &[i32], _: &_| mix(d, channels, &b, |x| x as f32 / 2_147_483_648.0), err, None), + cpal::SampleFormat::U8 => device.build_input_stream(cfg.config(), move |d: &[u8], _: &_| mix(d, channels, &b, |x| (x as f32 - 128.0) / 128.0), err, None), _ => return "fail", }; let Ok(stream) = stream else { return "fail" }; diff --git a/src/model.rs b/src/model.rs index fce0850..b6e4633 100644 --- a/src/model.rs +++ b/src/model.rs @@ -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::(&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::(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,8 +437,13 @@ 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() { - let _ = std::fs::rename(&tmp, &p); + // 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); + } } } } @@ -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(); diff --git a/src/notify.rs b/src/notify.rs index a344f18..d3ba116 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -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)] diff --git a/src/pages.rs b/src/pages.rs index 59a0c75..c8e52df 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -87,8 +87,10 @@ pub fn describe(name: &str, args: &Value) -> String { } fn find<'a>(pages: &'a [Page], bot: u64, id: Option, 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)), } } diff --git a/src/platform.rs b/src/platform.rs index cf11594..48ffa2f 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -34,7 +34,9 @@ const PAIRS: &[(&str, &str)] = &[ /// Mac wording → this system's wording (unchanged on macOS). pub fn localize(s: &str) -> String { - if cfg!(target_os = "macos") || !s.contains("Mac") && !s.contains("Finder") { + // "macOS Keychain" and "macOS 鑰匙圈" contain no "Mac" (lowercase m), so the quick + // guard has to test "macOS" as well — those pairs used to never apply. + if cfg!(target_os = "macos") || !s.contains("Mac") && !s.contains("macOS") && !s.contains("Finder") { return s.to_string(); } let mut out = s.to_string(); @@ -75,9 +77,14 @@ mod tests { fn dialog(zenity: &[&str], kdialog: &[&str]) -> Option { for (cmd, args) in [("zenity", zenity), ("kdialog", kdialog)] { - if let Ok(o) = std::process::Command::new(cmd).args(args).stderr(std::process::Stdio::null()).output() { + match std::process::Command::new(cmd).args(args).stderr(std::process::Stdio::null()).output() { // Exit code 1 = the person pressed Cancel: don't try the other one. - return o.status.success().then(|| String::from_utf8_lossy(&o.stdout).trim_end_matches('\n').to_string()); + Ok(o) if o.status.success() => return Some(String::from_utf8_lossy(&o.stdout).trim_end_matches('\n').to_string()), + Ok(o) if o.status.code() == Some(1) => return None, + // Missing binary (Err) or a tool error (zenity 5, kdialog 2): fall through to + // the other tool — the old code returned None on any spawn, so a broken zenity + // hid the working kdialog. + _ => continue, } } None diff --git a/src/portal.rs b/src/portal.rs index 47c5506..56a7feb 100644 --- a/src/portal.rs +++ b/src/portal.rs @@ -37,10 +37,16 @@ pub struct Screen { struct Live { tx: Sender, screens: Vec, + /// Which started session this is, so its own thread (and only it) can clear LIVE when it ends. + id: u64, } static LIVE: Mutex> = Mutex::new(None); static LAST_ERROR: Mutex = 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> { + 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::, String>>(); let (tx, rx) = channel::(); std::thread::spawn(move || { @@ -147,12 +155,17 @@ fn ensure() -> Option> { } } 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)) => { diff --git a/src/preview.rs b/src/preview.rs index 0402a2d..632cf3e 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -114,7 +114,8 @@ pub fn system_open(target: impl AsRef) -> 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 { - 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 { s } +/// Find `` 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 { + 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 { + 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 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!("")).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!("")).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 { + // 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 { /// 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 { diff --git a/src/providers.rs b/src/providers.rs index f169b95..594951f 100644 --- a/src/providers.rs +++ b/src/providers.rs @@ -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.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,29 +773,55 @@ 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::>()); } 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)); - } - // SAND-E0418 turnBackendUnreachable: retryable. - let resp = r.send_json(&body).map_err(|e| Failure::retry(connect_error(req.kind, &e.to_string())))?; - let status = resp.status().as_u16(); - if status >= 400 { - let ra = retry_after_ms(&resp); - let text = resp.into_body().read_to_string().unwrap_or_default(); - return Err(http_error(status, &text, ra)); + 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> = 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())))?; + let status = resp.status().as_u16(); + if status >= 400 { + let ra = retry_after_ms(&resp); + 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 = 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![ diff --git a/src/remote.rs b/src/remote.rs index 4be9713..c4213dc 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -142,7 +142,8 @@ pub struct Hub { cmds: Mutex>, repaint: Mutex>>, settings: Mutex, - fails: Mutex<(u32, Option)>, + /// Wrong logins, counted per source (one noisy LAN device must not lock out the owner). + fails: Mutex>, relay_status: Mutex, sync: Mutex>, } @@ -162,7 +163,7 @@ pub struct SyncReq { pub fn hub() -> &'static Hub { static H: OnceLock = 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 mut f = hub().fails.lock().unwrap(); + 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::(); 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::() { + // 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 { + 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>> = 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), 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 { + // 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 { /// Download and open the newest backup of this account (for a new computer). pub fn restore(url: &str, user: &str, password: &str) -> Result, 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> { 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> { + static P: OnceLock>> = 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 { + if me.is_empty() { + return vec![]; + } + let mut v: Vec = 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() }; diff --git a/src/routines.rs b/src/routines.rs index 8ae24e2..3321cb6 100644 --- a/src/routines.rs +++ b/src/routines.rs @@ -130,6 +130,26 @@ pub fn validate(mut s: Schedule) -> Result { 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 { 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 { } 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 { 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); diff --git a/src/setup.rs b/src/setup.rs index a4cd7ab..9a381ed 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -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; } diff --git a/src/share.rs b/src/share.rs index f7280c9..448f8a7 100644 --- a/src/share.rs +++ b/src/share.rs @@ -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") { diff --git a/src/teach.rs b/src/teach.rs index f674b7a..acb2bc3 100644 --- a/src/teach.rs +++ b/src/teach.rs @@ -39,6 +39,7 @@ static EVENTS: Mutex> = Mutex::new(Vec::new()); static EVENT_FRAMES: Mutex> = Mutex::new(Vec::new()); static FRAMES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); static FRAME_STOP: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +static FRAME_HANDLE: Mutex>> = Mutex::new(None); static REC_DIR: Mutex> = Mutex::new(None); /// Screen pictures per second while recording (estimated; the original records video). pub const FRAMES_PER_SEC: u64 = 2; @@ -59,6 +60,10 @@ pub fn start(bot_name: &str) -> Result<(), String> { } EVENTS.lock().unwrap().clear(); EVENT_FRAMES.lock().unwrap().clear(); + // Zero the frame counter before the listener starts: events that arrive in the first + // half second used to carry the *previous* recording's frame numbers (start_frames + // cleared it only after mac::start), mapping steps to pictures from an old demo. + FRAMES.store(0, std::sync::atomic::Ordering::SeqCst); mac::start()?; *RECORDING.lock().unwrap() = Some((bot_name.to_string(), Instant::now())); start_frames(); @@ -78,7 +83,7 @@ fn start_frames() { *REC_DIR.lock().unwrap() = Some(dir.clone()); FRAMES.store(0, std::sync::atomic::Ordering::SeqCst); FRAME_STOP.store(false, std::sync::atomic::Ordering::SeqCst); - std::thread::spawn(move || { + *FRAME_HANDLE.lock().unwrap() = Some(std::thread::spawn(move || { while !FRAME_STOP.load(std::sync::atomic::Ordering::SeqCst) { let t0 = Instant::now(); if crate::computer::screen_supported() && crate::computer::screen_capture_ok() { @@ -98,16 +103,38 @@ fn start_frames() { std::thread::sleep(rest); } } - }); + })); } /// Stop the pictures and turn them into a video when ffmpeg is there. Returns (dir, video). fn stop_frames() -> Option<(std::path::PathBuf, String)> { FRAME_STOP.store(true, std::sync::atomic::Ordering::SeqCst); - std::thread::sleep(std::time::Duration::from_millis(700)); + // Let the capturing thread finish its current picture and exit (it ends within a second). + // The old fixed 700 ms sleep raced slow captures (dirty files after the video was made) + // and froze the window for its whole duration. + if let Some(h) = FRAME_HANDLE.lock().unwrap().take() { + let _ = h.join(); + } let dir = REC_DIR.lock().unwrap().take()?; - let video = make_video(&dir).unwrap_or_default(); - Some((dir, video)) + // Encoding runs off the window thread (ffmpeg on a long demo takes seconds; the window + // used to freeze for all of it). The mp4 file appears when it is done. + if dir.join("frame_00000.jpg").exists() { + let d = dir.clone(); + std::thread::spawn(move || { + if make_video(&d).is_some() { + // The video holds the motion; drop the single pictures it consumed. + if let Ok(rd) = std::fs::read_dir(&d) { + for e in rd.flatten() { + if e.file_name().to_string_lossy().starts_with("frame_") { + let _ = std::fs::remove_file(e.path()); + } + } + } + } + }); + return Some((dir.clone(), dir.join("recording.mp4").display().to_string())); + } + Some((dir, String::new())) } /// frame_*.jpg → recording.mp4 (H.264, like the original's render proxy). @@ -134,6 +161,9 @@ pub fn make_video(dir: &std::path::Path) -> Option { /// Stop and write up the steps. pub fn finish(name: &str) -> Demo { mac::stop(); + // The listener stops asynchronously (the X11 one polls every 150 ms): give the last + // events of the demo time to arrive — they used to be cut off the recording. + std::thread::sleep(std::time::Duration::from_millis(300)); *RECORDING.lock().unwrap() = None; let raw = std::mem::take(&mut *EVENTS.lock().unwrap()); let frames_at = std::mem::take(&mut *EVENT_FRAMES.lock().unwrap()); @@ -150,16 +180,7 @@ pub fn finish(name: &str) -> Demo { let dst = dir.join(format!("step_{:02}.jpg", i + 1)); shots.push(if std::fs::copy(&src, &dst).is_ok() { dst.display().to_string() } else { String::new() }); } - // The video holds the rest. - if !video.is_empty() { - if let Ok(rd) = std::fs::read_dir(dir) { - for e in rd.flatten() { - if e.file_name().to_string_lossy().starts_with("frame_") { - let _ = std::fs::remove_file(e.path()); - } - } - } - } + // The frame pictures are removed by the encoder thread once the video is written. } Demo { name: name.trim().to_string(), created: chrono::Local::now().format("%Y-%m-%d %H:%M").to_string(), steps: compiled.into_iter().map(|c| c.0).collect(), video, shots } } @@ -382,6 +403,21 @@ mod mac { } static TAP: Mutex = Mutex::new(0); + // The foreground app barely moves between keystrokes, and the window-list query is the + // slowest call in the tap callback — slow callbacks make macOS disable the tap (events + // lost until it is re-enabled). Answer keystrokes from a short cache. + static FRONT_CACHE: Mutex> = Mutex::new(None); + fn front_cached() -> String { + let mut c = FRONT_CACHE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((t, n)) = c.as_ref() { + if t.elapsed() < std::time::Duration::from_millis(400) { + return n.clone(); + } + } + let n = crate::computer::frontmost_app().unwrap_or_default(); + *c = Some((Instant::now(), n.clone())); + n + } extern "C" fn callback(_proxy: *mut c_void, kind: u32, e: *mut c_void, _user: *mut c_void) -> *mut c_void { unsafe { @@ -397,7 +433,7 @@ mod mac { CGEventKeyboardGetUnicodeString(e, buf.len(), &mut n, buf.as_mut_ptr()); let text = String::from_utf16_lossy(&buf[..n.min(buf.len())]); let code = CGEventGetIntegerValueField(e, 9); - let app = crate::computer::frontmost_app().unwrap_or_default(); + let app = front_cached(); push(Raw::Key { text, code, flags: CGEventGetFlags(e), app }); } 22 => { @@ -445,7 +481,7 @@ mod mac { match rx.recv_timeout(std::time::Duration::from_secs(3)) { Ok(true) => Ok(()), _ => { - let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent").spawn(); + let _ = crate::computer::spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent")); Err("macOS 還沒讓 AnyBot 觀看你的操作。我已經打開「系統設定 → 隱私權與安全性 → 輸入監控」:把 AnyBot(或「終端機」)打開,再按一次「開始錄製」。".into()) } } @@ -491,7 +527,17 @@ mod mac { let _ = tx.send(Err(e)); } }); - rx.recv_timeout(std::time::Duration::from_secs(3)).unwrap_or(Err("沒辦法開始錄製(X11 沒有回應)".into())) + // On a timeout the record thread may still be spinning up: close the gate so its + // keep_going() ends it — a thread left running meant a second RECORD context + // (double events) on the next try, plus a leaked X connection. + match rx.recv_timeout(std::time::Duration::from_secs(3)) { + Ok(Ok(())) => Ok(()), + Ok(Err(e)) => Err(e), + Err(_) => { + STOP.store(true, Ordering::SeqCst); + Err("沒辦法開始錄製(X11 沒有回應)".into()) + } + } } /// X input → the same events the macOS tap gives (key codes and flags in macOS terms, so diff --git a/src/tools.rs b/src/tools.rs index 71800ea..f5b6cb6 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -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 = 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 { +fn wrapper_for(place: &Place, cmd: &str, interactive: bool, job: &str) -> Option { 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 { 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 { + let joined = if p.starts_with('/') { PathBuf::from(p) } else { dir.join(p) }; + let mut parts: Vec = 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 { } fn shell(cmd: &str, workdir: &Path, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, seal: Option<&Path>) -> Result { - 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 { - 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>) -> Result { + 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 { +fn run_command(mut command: Command, cmd: &str, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, kill: Option>) -> Result { 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 { /// 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 { use std::io::Write; - let t = terminals().lock().unwrap(); - let term = t.get(&id).ok_or_else(|| format!("沒有背景指令 #{id}"))?; - if term.exit.lock().unwrap().is_some() { + // 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}"))?; + (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 { *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 { /// 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; } } diff --git a/src/triggers.rs b/src/triggers.rs index f9d0267..4021d79 100644 --- a/src/triggers.rs +++ b/src/triggers.rs @@ -315,9 +315,12 @@ pub fn github_runs(runs: &Value, seen: &mut HashSet, first: bool) -> Vec`. pub fn status_changes(prev: &mut HashMap, 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, slack_token: String, tx: Sender, repaint: Arc) { 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 = 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, }; - first = false; + 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; } diff --git a/src/tunnel.rs b/src/tunnel.rs index a2b2310..633e1e7 100644 --- a/src/tunnel.rs +++ b/src/tunnel.rs @@ -238,6 +238,10 @@ fn keep(mut ws: WebSocket, 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); } } diff --git a/src/vault.rs b/src/vault.rs index 2246b95..5ee0831 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -178,7 +178,10 @@ fn onepassword(host: &str) -> Option { 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 { 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 { 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, diff --git a/src/vault_store.rs b/src/vault_store.rs index 273d418..3f89d6e 100644 --- a/src/vault_store.rs +++ b/src/vault_store.rs @@ -381,8 +381,12 @@ fn store_key(key: &[u8]) -> Result<(), String> { fn key(create: bool) -> Result { 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)?; diff --git a/src/voice.rs b/src/voice.rs index 29c0816..3c59f2a 100644 --- a/src/voice.rs +++ b/src/voice.rs @@ -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()), diff --git a/src/web.rs b/src/web.rs index 8315ed3..e3ecdbc 100644 --- a/src/web.rs +++ b/src/web.rs @@ -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 { +pub fn specs_for(here: &str, devices: &[String]) -> Vec { let local = crate::botbrowser::available(); let mut v = specs_inner(local || !devices.is_empty()); if !devices.is_empty() { let mut names: Vec = 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 { 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) diff --git a/src/wl.rs b/src/wl.rs index e6da344..413a4e4 100644 --- a/src/wl.rs +++ b/src/wl.rs @@ -92,8 +92,29 @@ static WL: Mutex> = 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::() as libc::socklen_t) + }; + if rc == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + fn connect() -> Option { 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::(&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 { 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 { 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 { static STARTED: std::sync::Once = std::sync::Once::new(); STARTED.call_once(|| { std::thread::spawn(|| { - let Ok(conn) = Connection::connect_to_env() else { return }; - let Ok((globals, mut queue)) = registry_queue_init::(&conn) else { return }; - let qh = queue.handle(); - let Ok(seat) = globals.bind::(&qh, 1..=1, ()) else { return }; - let Ok(notifier) = globals.bind::(&qh, 1..=1, ()) else { return }; - 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; + // 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::(&conn) else { return }; + let qh = queue.handle(); + let Ok(seat) = globals.bind::(&qh, 1..=1, ()) else { return }; + let Ok(notifier) = globals.bind::(&qh, 1..=1, ()) else { return }; + // 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 diff --git a/src/x11.rs b/src/x11.rs index d75ad6b..4cfd078 100644 --- a/src/x11.rs +++ b/src/x11.rs @@ -53,16 +53,16 @@ pub fn capture() -> Result { 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(()) }