Merge pull request 'Audit fixes: sync chain, box, desktop, install, UI (B1-B30, F1-F5)' (#5) from brain-computers into main
Reviewed-on: #5
This commit is contained in:
commit
9d8f7bbead
|
|
@ -351,6 +351,7 @@ dependencies = [
|
|||
"hayro",
|
||||
"iana-time-zone",
|
||||
"image",
|
||||
"libc",
|
||||
"memmap2",
|
||||
"mermaid-rs-renderer",
|
||||
"pdf-extract",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
7
Makefile
7
Makefile
|
|
@ -11,7 +11,10 @@
|
|||
export PATH := $(HOME)/.cargo/bin:$(PATH)
|
||||
CARGO ?= cargo
|
||||
UNAME := $(shell uname -s)
|
||||
VERSION ?= $(shell git describe --tags --always 2>/dev/null | sed 's/^v//' || echo dev)
|
||||
# `git describe` prints nothing outside a repo; sed passes the empty string through with
|
||||
# status 0, so "|| echo dev" never fired and VERSION came out blank ("AnyBot ", dist files
|
||||
# named ".tar.gz"). grep . fails on empty input, which makes the fallback work.
|
||||
VERSION ?= $(shell git describe --tags --always 2>/dev/null | sed 's/^v//' | grep . || echo dev)
|
||||
TARGET := $(or $(CARGO_TARGET_DIR),target)
|
||||
APPDIR ?= $(HOME)/Applications
|
||||
BIN := $(TARGET)/release/anybot
|
||||
|
|
@ -45,7 +48,7 @@ build: app relay
|
|||
|
||||
check-rust:
|
||||
@command -v $(CARGO) >/dev/null 2>&1 || { echo; echo " ✗ 找不到 Rust(cargo)。先執行:make deps"; echo; exit 1; }
|
||||
@v=$$(rustc --version | sed -n 's/^rustc 1\.\([0-9]*\).*/\1/p'); [ "$${v:-0}" -ge 95 ] || { echo; echo " ✗ Rust 太舊($$(rustc --version)),要 1.95 以上。執行:rustup update stable(或 make deps)"; echo; exit 1; }
|
||||
@set -- $$(rustc --version | sed -n 's/^rustc \([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1 \2/p'); { [ "$${1:-0}" -gt 1 ] || { [ "$${1:-0}" -eq 1 ] && [ "$${2:-0}" -ge 95 ]; }; } || { echo; echo " ✗ Rust 太舊($$(rustc --version)),要 1.95 以上。執行:rustup update stable(或 make deps)"; echo; exit 1; }
|
||||
|
||||
app: check-rust
|
||||
$(CARGO) build --release --bin anybot
|
||||
|
|
|
|||
26
box/start.sh
26
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'
|
||||
# VNC login key: the app starts the box with VNC_PASSWORD and opens the viewer with it.
|
||||
# Without it (an older app) the desktop stays keyless as before.
|
||||
if [ -n "${VNC_PASSWORD:-}" ]; then
|
||||
mkdir -p /home/bot/.vnc
|
||||
printf '%s' "$VNC_PASSWORD" | x11vnc -storepasswd /home/bot/.vnc/pass >/dev/null 2>&1
|
||||
x11vnc -display :1 -forever -shared -quiet -rfbauth /home/bot/.vnc/pass -rfbport 5900 -localhost >/tmp/x11vnc.log 2>&1 &
|
||||
else
|
||||
x11vnc -display :1 -forever -shared -nopw -quiet -rfbport 5900 -localhost >/tmp/x11vnc.log 2>&1 &
|
||||
fi
|
||||
websockify --web /usr/share/novnc 6080 localhost:5900 >/tmp/novnc.log 2>&1 &
|
||||
WEBSOCKIFY_PID=$!
|
||||
# Say ready only once the viewer's port listens: the app opens noVNC on this flag, and a too
|
||||
# early file meant the first viewer load 404'd.
|
||||
(
|
||||
for i in $(seq 1 100); do
|
||||
python3 -c "import socket; socket.create_connection(('127.0.0.1', 6080), 0.5)" 2>/dev/null && break
|
||||
sleep 0.2
|
||||
done
|
||||
echo "anybot-box ready" > /tmp/anybot-box.ready
|
||||
wait
|
||||
) &
|
||||
# When the desktop's heart stops, the container ends too (it used to sit there as Running
|
||||
# with a dead X server, and every screen action failed forever).
|
||||
wait $XVFB_PID
|
||||
kill $WEBSOCKIFY_PID 2>/dev/null
|
||||
pkill -f x11vnc 2>/dev/null
|
||||
pkill -u bot fluxbox 2>/dev/null
|
||||
exit 1
|
||||
|
|
|
|||
45
install.sh
45
install.sh
|
|
@ -23,7 +23,16 @@ MODE=${1:-auto}
|
|||
[ $# -gt 0 ] && shift
|
||||
case $MODE in
|
||||
app|hands|headless|server|deps|auto) ;;
|
||||
-h|--help|help) sed -n '2,14p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
-h|--help|help)
|
||||
# 用 curl | sh 執行時 $0 是「sh」,讀不到腳本原文:改印內嵌的說明。
|
||||
usage=$(sed -n '2,14p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//' || true)
|
||||
[ -n "$usage" ] || usage='AnyBot 一鍵安裝:
|
||||
sh install.sh 桌面 App(Mac、有螢幕的 Linux)
|
||||
sh install.sh hands [網址 配對碼] 沒有螢幕的 Linux,接成大腦的手腳
|
||||
sh install.sh server 伺服器:帳號伺服器 + 大腦 + HTTPS(Docker)
|
||||
sh install.sh deps 只裝建置需要的東西(之後自己 make)
|
||||
環境變數:ANYBOT_DIR(原始碼位置,預設 ~/AnyBot)、ANYBOT_BRANCH(預設 main)。'
|
||||
printf '%s\n' "$usage"; exit 0 ;;
|
||||
*) echo "不認得「${MODE}」。可以用:app、hands、server、deps"; exit 1 ;;
|
||||
esac
|
||||
[ "$MODE" = headless ] && MODE=hands
|
||||
|
|
@ -43,6 +52,9 @@ have() { command -v "$1" >/dev/null 2>&1; }
|
|||
TTY=/dev/tty
|
||||
[ -r "$TTY" ] && [ -w "$TTY" ] && (exec <"$TTY") 2>/dev/null || TTY=
|
||||
|
||||
# PATH 提示要看的是使用者登入後的 PATH,不是腳本自己改過的(下面會加 ~/.local/bin)。
|
||||
START_PATH=$PATH
|
||||
|
||||
# ---------- 這台是什麼 ----------
|
||||
OS=$(uname -s); ARCH=$(uname -m)
|
||||
case $OS in
|
||||
|
|
@ -79,6 +91,8 @@ printf '\n %s◆ AnyBot 安裝%s %s%s · %s %s%s\n' "$C$B" "$N" "$D" "$TITLE"
|
|||
|
||||
# ---------- 系統套件 ----------
|
||||
# apt:只裝這個發行版有的名字(例如 Ubuntu 24.04 的 libasound2 叫 libasound2t64)。
|
||||
# 最後加 true:清單最後一個套件不存在時,最後一条 apt-cache 判斷會失敗,
|
||||
# 在 set -e 下整個腳本會無訊息退出(呼叫端是 pkgs=$(apt_pick …))。
|
||||
apt_pick() {
|
||||
for p in "$@"; do
|
||||
case $p in
|
||||
|
|
@ -89,6 +103,16 @@ apt_pick() {
|
|||
*) apt-cache show "$p" >/dev/null 2>&1 && echo "$p" ;;
|
||||
esac
|
||||
done
|
||||
true
|
||||
}
|
||||
|
||||
# zypper:同 apt_pick,裝之前先過濾掉這台找不到的名字(SUSE 各版本套件名差異大,
|
||||
# 一個名字不存在會讓整組安裝失敗)。
|
||||
zypper_pick() {
|
||||
for p in "$@"; do
|
||||
zypper --non-interactive info "$p" >/dev/null 2>&1 && echo "$p"
|
||||
done
|
||||
true
|
||||
}
|
||||
|
||||
linux_packages() {
|
||||
|
|
@ -137,8 +161,14 @@ linux_packages() {
|
|||
;;
|
||||
zypper)
|
||||
base="gcc gcc-c++ make pkg-config git curl alsa-devel"
|
||||
extra=""
|
||||
# app/hands 也要有這台该有的套件(之前只有 base,桌面模式缺 xkbcommon/GL/通知…,
|
||||
# 編譯或執行時才炸)。名字逐個過濾,找不到的跳過。
|
||||
[ "$1" = app ] && extra="libxkbcommon-x11-0 Mesa libGL1 xdg-utils libnotify-tools libnotify4 libsecret-1-0 espeak zenity xclip wl-clipboard ffmpeg fonts-noto"
|
||||
[ "$1" = hands ] && extra="xorg-x11-server-Xvfb openbox at-spi2-core at-spi2-atk-common dbus-1-x11 xauth fonts-noto"
|
||||
[ "$1" = server ] && base="curl git"
|
||||
$SUDO zypper -n install $base || die "安裝系統套件失敗。" "sudo zypper install $base"
|
||||
pkgs=$(zypper_pick $base $extra)
|
||||
$SUDO zypper -n install $pkgs || die "安裝系統套件失敗。" "sudo zypper install $pkgs"
|
||||
;;
|
||||
*)
|
||||
die "認不出這台的套件管理程式(apt、dnf、pacman、zypper 都沒有)。" \
|
||||
|
|
@ -157,8 +187,11 @@ mac_tools() {
|
|||
# ---------- Rust ----------
|
||||
rust_ok() {
|
||||
have cargo && have rustc || return 1
|
||||
minor=$(rustc --version 2>/dev/null | sed -n 's/^rustc 1\.\([0-9]*\).*/\1/p')
|
||||
[ -n "$minor" ] && [ "$minor" -ge $RUST_MIN ]
|
||||
# 抓「主版號 次版號」:舊寫法死鎖在 1.x,rustc 2.x 會被当成「太舊」。
|
||||
set -- $(rustc --version 2>/dev/null | sed -n 's/^rustc \([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1 \2/p')
|
||||
[ $# -ge 2 ] || return 1
|
||||
[ "$1" -gt 1 ] && return 0
|
||||
[ "$1" -eq 1 ] && [ "$2" -ge "$RUST_MIN" ]
|
||||
}
|
||||
ensure_rust() {
|
||||
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
|
||||
|
|
@ -166,6 +199,8 @@ ensure_rust() {
|
|||
if have rustup; then
|
||||
info "Rust 太舊,更新中(rustup update stable)…"
|
||||
rustup update stable >/dev/null 2>&1 && rustup default stable >/dev/null 2>&1 || true
|
||||
# rustup 裝在 ~/.cargo/bin;這個終端機可能還沒有它(--no-modify-path 裝的)。
|
||||
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
|
||||
else
|
||||
if have rustc; then warn "系統的 Rust $(rustc --version | cut -d' ' -f2) 太舊(要 1.$RUST_MIN 以上),另外用 rustup 裝新版"; fi
|
||||
info "安裝 Rust(rustup,裝在 ~/.cargo,不用 root)…"
|
||||
|
|
@ -326,6 +361,6 @@ case $MODE in
|
|||
if ! have docker; then info "Bot 自己的電腦(一個隔離的桌面)需要 Docker,要用時 AnyBot 會告訴你怎麼裝。"; fi
|
||||
info "要更新:再執行一次同一行指令。"
|
||||
fi
|
||||
case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) [ $PLAT = linux ] && info "(終端機找不到 anybot 的話,重新登入,或把 ~/.local/bin 加進 PATH)" ;; esac
|
||||
case ":$START_PATH:" in *":$HOME/.local/bin:"*) ;; *) [ $PLAT = linux ] && info "(終端機找不到 anybot 的話,重新登入,或把 ~/.local/bin 加進 PATH)" ;; esac
|
||||
;;
|
||||
esac
|
||||
|
|
|
|||
|
|
@ -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(); }
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ const ICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 9
|
|||
const HOST_POLL_SECS: u64 = 25;
|
||||
/// How long a phone's request waits for the AnyBot to answer (its own long-poll is 20 s).
|
||||
const FORWARD_SECS: u64 = 35;
|
||||
/// Model requests relayed to a person's computer (model_fetch) are ordinary LLM latency:
|
||||
/// the asking side waits minutes, so the relay must not answer first with a timeout.
|
||||
const LONG_FORWARD_SECS: u64 = 610;
|
||||
/// How long a 配對碼 lasts.
|
||||
const PAIR_SECS: u64 = 600;
|
||||
/// How long a request may wait to be picked up before the host counts as gone.
|
||||
|
|
@ -186,7 +189,9 @@ struct Relay {
|
|||
queues: Mutex<HashMap<String, VecDeque<Value>>>,
|
||||
arrived: Condvar,
|
||||
/// Where each forwarded request's answer goes.
|
||||
waiting: Mutex<HashMap<String, Sender<Answer>>>,
|
||||
/// Requests waiting for an answer: job id → (its answer, the host it was sent to —
|
||||
/// so only that host may answer it).
|
||||
waiting: Mutex<HashMap<String, (Sender<Answer>, String)>>,
|
||||
/// AnyBots connected the lasting way (WebSocket), and which requests went over which.
|
||||
sockets: Mutex<HashMap<String, Sock>>,
|
||||
flights: Mutex<HashMap<String, (String, u64)>>,
|
||||
|
|
@ -301,15 +306,16 @@ impl Relay {
|
|||
return Answer::error(503, OFFLINE);
|
||||
}
|
||||
let id = httpd::random_hex(12);
|
||||
let secs = if path == "/api/device/run" { LONG_FORWARD_SECS } else { FORWARD_SECS };
|
||||
let (tx, rx) = channel();
|
||||
self.waiting.lock().unwrap().insert(id.clone(), tx);
|
||||
self.waiting.lock().unwrap().insert(id.clone(), (tx, host_id.to_string()));
|
||||
let head = json!({"t": "job", "id": id, "method": method, "path": path, "query": query, "user": who.0, "role": who.1, "z": z, "z_ok": z_ok, "from": from});
|
||||
// Connected the lasting way: straight down that connection.
|
||||
let sock = self.sockets.lock().unwrap().get(host_id).map(|s| (s.tx.clone(), s.serial));
|
||||
if let Some((stx, serial)) = sock {
|
||||
self.flights.lock().unwrap().insert(id.clone(), (host_id.to_string(), serial));
|
||||
let sent = stx.send(wire::pack(&head, body)).is_ok();
|
||||
let r = if sent { rx.recv_timeout(Duration::from_secs(FORWARD_SECS)).ok() } else { None };
|
||||
let r = if sent { rx.recv_timeout(Duration::from_secs(secs)).ok() } else { None };
|
||||
self.waiting.lock().unwrap().remove(&id);
|
||||
self.flights.lock().unwrap().remove(&id);
|
||||
return match r {
|
||||
|
|
@ -328,7 +334,7 @@ impl Relay {
|
|||
if r.is_err() {
|
||||
let queued = self.queues.lock().unwrap().get(host_id).map(|q| q.iter().any(|j| j["id"].as_str() == Some(id.as_str()))).unwrap_or(false);
|
||||
if !queued {
|
||||
r = rx.recv_timeout(Duration::from_secs(FORWARD_SECS - PICKUP_SECS));
|
||||
r = rx.recv_timeout(Duration::from_secs(secs.max(PICKUP_SECS + 1) - PICKUP_SECS));
|
||||
}
|
||||
}
|
||||
self.waiting.lock().unwrap().remove(&id);
|
||||
|
|
@ -416,7 +422,7 @@ impl Relay {
|
|||
}
|
||||
let lost: Vec<String> = self.flights.lock().unwrap().iter().filter(|(_, (host, s))| *host == h.id && *s == serial).map(|(id, _)| id.clone()).collect();
|
||||
for id in lost {
|
||||
if let Some(w) = self.waiting.lock().unwrap().remove(&id) {
|
||||
if let Some((w, _)) = self.waiting.lock().unwrap().remove(&id) {
|
||||
let _ = w.send(Answer::error(503, GONE));
|
||||
}
|
||||
}
|
||||
|
|
@ -427,11 +433,18 @@ impl Relay {
|
|||
let s = |k: &str| head[k].as_str().unwrap_or("").to_string();
|
||||
match head["t"].as_str().unwrap_or("") {
|
||||
"reply" => {
|
||||
if let Some(w) = self.waiting.lock().unwrap().remove(&s("id")) {
|
||||
// Only the host the job went to may answer it.
|
||||
let w = {
|
||||
let mut waiting = self.waiting.lock().unwrap();
|
||||
match waiting.get(&s("id")) {
|
||||
Some((_, target)) if *target == h.id => waiting.remove(&s("id")).map(|(w, _)| w),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let Some(w) = w else { return };
|
||||
let headers = head["headers"].as_array().into_iter().flatten().filter_map(|x| Some((x[0].as_str()?.to_string(), x[1].as_str()?.to_string()))).collect();
|
||||
let _ = w.send(Answer { status: head["status"].as_u64().unwrap_or(502) as u16, ctype: s("ctype"), headers, body, z: head["z"].as_bool().unwrap_or(false) });
|
||||
}
|
||||
}
|
||||
// One AnyBot asks another of the same account (大腦 sync, 裝置連結).
|
||||
"call" => {
|
||||
let (id, to, path) = (s("id"), s("to"), s("path"));
|
||||
|
|
@ -620,10 +633,16 @@ impl Relay {
|
|||
}
|
||||
}
|
||||
("POST", ["relay", "host", "reply", id]) => {
|
||||
if self.host(&req).is_none() {
|
||||
return Resp::error(401, "這台電腦的金鑰無效");
|
||||
let Some(h) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") };
|
||||
// Only the host the job was sent to may answer it.
|
||||
let tx = {
|
||||
let mut waiting = self.waiting.lock().unwrap();
|
||||
match waiting.get(*id) {
|
||||
Some((_, target)) if *target == h.id => waiting.remove(*id).map(|(w, _)| w),
|
||||
_ => None,
|
||||
}
|
||||
if let Some(tx) = self.waiting.lock().unwrap().remove(*id) {
|
||||
};
|
||||
if let Some(tx) = tx {
|
||||
let _ = tx.send(Answer::from_json(&req.json()));
|
||||
}
|
||||
Resp::json(&json!({"ok": true}))
|
||||
|
|
@ -724,8 +743,26 @@ impl Relay {
|
|||
let d = self.data.lock().unwrap();
|
||||
Resp::json(&json!({"hosts": d.hosts.iter().filter(|h| h.user == me.user).map(|h| self.host_json(h)).collect::<Vec<_>>()}))
|
||||
}
|
||||
// Signing out of the app unregisters this machine: its own key removes its own
|
||||
// registration, so a key left on a borrowed computer dies with the sign-out.
|
||||
("POST", ["relay", "host", "remove"]) => {
|
||||
let Some(h) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") };
|
||||
{
|
||||
let mut d = self.data.lock().unwrap();
|
||||
d.hosts.retain(|x| x.id != h.id);
|
||||
self.save(&d);
|
||||
}
|
||||
// Its open connection is closed, so it hears "the key is gone" at once.
|
||||
if let Some(s) = self.sockets.lock().unwrap().remove(&h.id) {
|
||||
let _ = s.tx.send(wire::pack(&json!({"t": "bye"}), &[]));
|
||||
}
|
||||
Resp::json(&json!({"ok": true}))
|
||||
}
|
||||
("PUT", ["relay", "backup"]) => {
|
||||
let Some(h) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") };
|
||||
if req.body.len() > 200 * 1024 * 1024 {
|
||||
return Resp::error(413, "備份太大了(最多 200 MB)");
|
||||
}
|
||||
let path = self.dir.join("backups").join(format!("{}.bin", httpd::sha256_hex(&h.user)));
|
||||
let tmp = path.with_extension("tmp");
|
||||
match std::fs::write(&tmp, &req.body).and_then(|_| std::fs::rename(&tmp, &path)) {
|
||||
|
|
@ -820,7 +857,14 @@ impl Relay {
|
|||
fn login(&self, req: &Req) -> Resp {
|
||||
let v = req.json();
|
||||
let user = v["user"].as_str().unwrap_or("").trim().to_lowercase();
|
||||
let key = format!("{}|{user}", req.header("x-forwarded-for").split(',').next().unwrap_or(&req.peer).trim());
|
||||
// Rate-limit by the real peer. A client writes its own first X-Forwarded-For entry, so
|
||||
// taking the first value lets anyone reset the counter by changing the header. Only when
|
||||
// RELAY_TRUST_PROXY=1 (behind a reverse proxy) use the proxy's appended (last) entry.
|
||||
let ip = match (std::env::var("RELAY_TRUST_PROXY").is_ok(), req.header("x-forwarded-for")) {
|
||||
(true, xff) if !xff.is_empty() => xff.rsplit(',').next().unwrap_or(xff).trim().to_string(),
|
||||
_ => req.peer.clone(),
|
||||
};
|
||||
let key = format!("{ip}|{user}");
|
||||
if self.too_many_fails(&key) {
|
||||
return Resp::error(429, "密碼錯太多次,請 5 分鐘後再試");
|
||||
}
|
||||
|
|
@ -1121,4 +1165,94 @@ mod tests {
|
|||
assert_eq!(post(port, "/relay/pair/claim", "", json!({"code": "000000", "name": "x"})).0, 429);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brain_mirror_link_answers_only_from_the_right_machine() {
|
||||
// One account with a brain, a hands machine, and a nosy third machine: the sync
|
||||
// channel (brain → relay → hands → relay → brain) must carry the right roles, must
|
||||
// take answers only from the machine a job went to, and a signing-out machine's key
|
||||
// must die with its registration.
|
||||
let dir = std::env::temp_dir().join(format!("anybot-relay-brain-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let relay = Arc::new(Relay::load(dir.clone()));
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
let r = relay.clone();
|
||||
std::thread::spawn(move || httpd::serve(l, Arc::new(move |req| r.handle(req)), Arc::new(|| true)));
|
||||
post(port, "/relay/signup", "", json!({"user": "boss", "password": "12345678"}));
|
||||
let s = post(port, "/relay/login", "", json!({"user": "boss", "password": "12345678", "device": "phone"})).1["token"].as_str().unwrap().to_string();
|
||||
let hb = post(port, "/relay/hosts", &s, json!({"name": "大腦"})).1;
|
||||
let btoken = hb["token"].as_str().unwrap().to_string();
|
||||
let hh = post(port, "/relay/hosts", &s, json!({"name": "手腳"})).1;
|
||||
let (hid, htoken) = (hh["id"].as_str().unwrap().to_string(), hh["token"].as_str().unwrap().to_string());
|
||||
let hm = post(port, "/relay/hosts", &s, json!({"name": "旁聽"})).1;
|
||||
let mtoken = hm["token"].as_str().unwrap().to_string();
|
||||
|
||||
// The hands machine: takes its jobs, lets the snooper see the id, answers a beat later.
|
||||
let (tx, rx) = channel::<String>();
|
||||
let ht = htoken.clone();
|
||||
std::thread::spawn(move || loop {
|
||||
let (st, body) = get(port, "/relay/host/next", &ht);
|
||||
if st == 200 {
|
||||
let job: Value = serde_json::from_slice(&body).unwrap();
|
||||
let id = job["id"].as_str().unwrap().to_string();
|
||||
let _ = tx.send(id.clone());
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
let answer = json!({"status": 200, "ctype": "application/json", "body": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, json!({"from": "hands", "role": job["role"], "path": job["path"]}).to_string()), "headers": []});
|
||||
post(port, &format!("/relay/host/reply/{id}"), &ht, answer);
|
||||
}
|
||||
});
|
||||
// The snooper answers with a lie as soon as it learns a job id.
|
||||
let sn = mtoken.clone();
|
||||
std::thread::spawn(move || {
|
||||
let id = rx.recv().unwrap();
|
||||
let lie = json!({"status": 200, "ctype": "application/json", "body": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, json!({"from": "snooper"}).to_string()), "headers": []});
|
||||
post(port, &format!("/relay/host/reply/{id}"), &sn, lie);
|
||||
});
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
|
||||
// 大腦 syncs 手腳's chats: the answer must come from the hands, with the mirror role.
|
||||
let v: Value = serde_json::from_slice(&get(port, &format!("/relay/hosts/{hid}/api/sync?since=0"), &btoken).1).unwrap();
|
||||
assert_eq!((v["from"].as_str(), v["role"].as_str(), v["path"].as_str()), (Some("hands"), Some("mirror"), Some("/api/sync")), "the brain's sync answer must be the hands' own");
|
||||
// 裝置連結: running a tool on the other machine carries the device role.
|
||||
let v: Value = post(port, &format!("/relay/hosts/{hid}/api/device/run"), &btoken, json!({"tool": "shell", "args": "{}", "who": "大腦"})).1;
|
||||
assert_eq!((v["from"].as_str(), v["role"].as_str()), (Some("hands"), Some("device")));
|
||||
|
||||
// Signing out: the machine's own key removes its registration; others can't reach it.
|
||||
assert_eq!(post(port, "/relay/host/remove", &mtoken, json!({})).0, 200);
|
||||
let hosts: Value = serde_json::from_slice(&get(port, "/relay/host/peers", &btoken).1).unwrap();
|
||||
assert_eq!(hosts["hosts"].as_array().map(|a| a.len()), Some(2));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forged_forwarded_for_cannot_outrun_the_login_lock() {
|
||||
// The login throttle follows the real peer: rewriting the first X-Forwarded-For
|
||||
// entry on every try used to reset the counter, so passwords could be guessed forever.
|
||||
let dir = std::env::temp_dir().join(format!("anybot-relay-throttle-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let relay = Arc::new(Relay::load(dir.clone()));
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
let r = relay.clone();
|
||||
std::thread::spawn(move || httpd::serve(l, Arc::new(move |req| r.handle(req)), Arc::new(|| true)));
|
||||
post(port, "/relay/signup", "", json!({"user": "boss", "password": "12345678"}));
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into();
|
||||
for i in 0..8 {
|
||||
let mut rr = agent
|
||||
.post(&format!("http://127.0.0.1:{port}/relay/login"))
|
||||
.header("X-Forwarded-For", &format!("10.9.{i}.{i}"))
|
||||
.send(json!({"user": "boss", "password": "wrong-password"}).to_string().as_bytes())
|
||||
.unwrap();
|
||||
let _ = rr.body_mut().read_to_string();
|
||||
}
|
||||
// Every fake IP was new; the one real peer is locked out — even for the right password.
|
||||
let rr = agent
|
||||
.post(&format!("http://127.0.0.1:{port}/relay/login"))
|
||||
.header("X-Forwarded-For", "10.9.99.99")
|
||||
.send(json!({"user": "boss", "password": "12345678"}).to_string().as_bytes())
|
||||
.unwrap();
|
||||
assert_eq!(rr.status().as_u16(), 429);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
150
src/agent.rs
150
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<tools::Target, String> {
|
|||
})
|
||||
}
|
||||
|
||||
enum BrowserAt {
|
||||
Here,
|
||||
Box,
|
||||
Device(String),
|
||||
}
|
||||
|
||||
/// Whose browser a browser step uses: the computer it names, else the bot's chosen computer.
|
||||
fn browser_place(job: &Job, name: &str, args: &str) -> BrowserAt {
|
||||
if name == "web_fetch" {
|
||||
return BrowserAt::Here;
|
||||
}
|
||||
let v = tools::args_of(args);
|
||||
let want = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty()).unwrap_or(&job.default_computer).to_string();
|
||||
let any_device = || job.computers.iter().find_map(|t| match &t.place {
|
||||
tools::Place::Device { host } => Some(host.clone()),
|
||||
_ => None,
|
||||
});
|
||||
match job.computers.iter().find(|t| t.name == want).map(|t| &t.place) {
|
||||
Some(tools::Place::Device { host }) => BrowserAt::Device(host.clone()),
|
||||
Some(tools::Place::Box { .. }) if crate::cloudbox::available() => BrowserAt::Box,
|
||||
// No browser here (a brain on a server): the 手腳 computer's.
|
||||
_ if !crate::botbrowser::available() => any_device().map(BrowserAt::Device).unwrap_or(BrowserAt::Here),
|
||||
_ => BrowserAt::Here,
|
||||
}
|
||||
}
|
||||
|
||||
/// Substring search with word boundaries on the ASCII parts: "mac" must not light up inside
|
||||
/// "macOS", and a device named "Ann" must not light up on "anniversary". Loose matching let
|
||||
/// unrelated words — including words from page text that arrives inside a message — unlock a
|
||||
/// computer the person never named, defeating the guard below.
|
||||
fn finds(hay: &str, needle: &str) -> bool {
|
||||
let hb: Vec<char> = hay.chars().collect();
|
||||
let nb: Vec<char> = needle.chars().collect();
|
||||
if nb.is_empty() || nb.len() > hb.len() {
|
||||
return false;
|
||||
}
|
||||
let alnum = |c: char| c.is_ascii_alphanumeric();
|
||||
'start: for i in 0..=hb.len() - nb.len() {
|
||||
if hb[i] != nb[0] {
|
||||
continue;
|
||||
}
|
||||
for j in 1..nb.len() {
|
||||
if hb[i + j] != nb[j] {
|
||||
continue 'start;
|
||||
}
|
||||
}
|
||||
if alnum(nb[0]) && i > 0 && alnum(hb[i - 1]) {
|
||||
continue;
|
||||
}
|
||||
if alnum(*nb.last().unwrap()) && i + nb.len() < hb.len() && alnum(hb[i + nb.len()]) {
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Words that mean a computer in the person's message.
|
||||
fn mentions(text: &str, t: &tools::Target) -> bool {
|
||||
let low = text.to_lowercase();
|
||||
if finds(&low, &t.name.to_lowercase()) {
|
||||
return true;
|
||||
}
|
||||
let words: &[&str] = match &t.place {
|
||||
_ if t.name == crate::model::BOT_COMPUTER_NAME => &["bot 的電腦", "bot的電腦", "你自己的電腦", "你的虛擬", "虛擬電腦", "雲端電腦"],
|
||||
_ if t.name == crate::model::THIS_COMPUTER_NAME => &["mac", "我的電腦", "我電腦", "我這台", "這台電腦", "本機", "我的筆電"],
|
||||
tools::Place::Device { host } => return !crate::remote::peer_name(host).is_empty() && finds(&low, &crate::remote::peer_name(host).to_lowercase()),
|
||||
_ => &[],
|
||||
};
|
||||
words.iter().any(|w| finds(&low, w))
|
||||
}
|
||||
|
||||
/// A step naming a computer other than the chosen one, which the person didn't ask for, goes
|
||||
/// to the chosen one.
|
||||
fn keep_to_chosen(job: &Job, history: &[Message], args: &str) -> String {
|
||||
let mut v = tools::args_of(args);
|
||||
let Some(named) = v.get("computer").and_then(|c| c.as_str()).map(|c| c.trim().to_string()).filter(|c| !c.is_empty()) else { return args.to_string() };
|
||||
if named == job.default_computer || !job.computers.iter().any(|t| t.name == job.default_computer) {
|
||||
return args.to_string();
|
||||
}
|
||||
let said = history.iter().rev().filter(|m| m.role == Role::User && !m.summary).take(3).map(|m| m.text.as_str()).collect::<Vec<_>>().join("\n");
|
||||
match job.computers.iter().find(|t| t.name == named) {
|
||||
Some(t) if mentions(&said, t) => args.to_string(),
|
||||
_ => {
|
||||
v["computer"] = serde_json::Value::String(job.default_computer.clone());
|
||||
v.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the call name a computer?
|
||||
fn names_a_computer(args: &str) -> bool {
|
||||
tools::args_of(args).get("computer").and_then(|c| c.as_str()).map(|c| !c.trim().is_empty()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The other computer (裝置連結) a call names, if it names one.
|
||||
fn device_named(job: &Job, args: &str) -> Option<String> {
|
||||
let v = tools::args_of(args);
|
||||
|
|
@ -235,8 +331,11 @@ fn maybe_compact(job: &Job, history: &mut Vec<Message>, percent: u64, cancel: &A
|
|||
return None;
|
||||
}
|
||||
let summary = Message { role: Role::Assistant, text: out.text.trim().to_string(), summary: true, via: job.via.clone(), at: crate::model::now_ms(), ..Default::default() };
|
||||
// The window shows the chat unfiltered (group chats drop some messages on the way here);
|
||||
// the timestamp of the first kept message is the landmark it can find the cut by.
|
||||
let boundary = history[cut].at;
|
||||
history.splice(..cut, [summary.clone()]);
|
||||
send(StreamEvent::Compacted(cut, summary));
|
||||
send(StreamEvent::Compacted(cut, summary, boundary));
|
||||
Some(cut)
|
||||
}
|
||||
|
||||
|
|
@ -283,9 +382,13 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
|||
if cancel.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let mut out = out;
|
||||
for c in out.tool_calls.iter_mut() {
|
||||
c.args = keep_to_chosen(&job, &history, &c.args);
|
||||
}
|
||||
let msg = Message {
|
||||
role: Role::Assistant,
|
||||
text: out.text,
|
||||
text: crate::i18n::reply_text(&out.text),
|
||||
thinking: out.thinking,
|
||||
via: job.via.clone(),
|
||||
tool_calls: out.tool_calls,
|
||||
|
|
@ -304,6 +407,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
|||
}
|
||||
continue;
|
||||
}
|
||||
// (Each call's computer was already kept to the chosen one, see keep_to_chosen.)
|
||||
for call in calls {
|
||||
if appctl::is_app_tool(&call.name) {
|
||||
// App actions run on the window's side, which also asks for deletions.
|
||||
|
|
@ -342,12 +446,13 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
|||
if call.name == "web_fetch" || (web::is_web_tool(&call.name) && !web::uses_this_computer(&call.name, &call.args)) {
|
||||
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
|
||||
// 裝置連結: the AnyBot browser of another computer (its own logins).
|
||||
let (output, ok, img) = match device_named(&job, &call.args).filter(|_| call.name != "web_fetch") {
|
||||
Some(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) {
|
||||
let (output, ok, img) = match browser_place(&job, &call.name, &call.args) {
|
||||
BrowserAt::Device(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) {
|
||||
Ok(v) => (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false), v["image"].as_str().map(String::from)),
|
||||
Err(e) => (e, false, None),
|
||||
},
|
||||
None => web::run_full(&call.name, &call.args, &cancel),
|
||||
BrowserAt::Box => crate::botbrowser::in_box(|| web::run_full(&call.name, &call.args, &cancel)),
|
||||
BrowserAt::Here => web::run_full(&call.name, &call.args, &cancel),
|
||||
};
|
||||
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
|
||||
let mut result = Message::tool_result(&call.id, &call.name, &output);
|
||||
|
|
@ -398,12 +503,19 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
|||
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
|
||||
let path = tools::args_of(&call.args)["path"].as_str().unwrap_or("").to_string();
|
||||
let local = match target_for(&job, &call.args) {
|
||||
Ok(tools::Target { place: tools::Place::Box { dir }, .. }) => crate::cloudbox::copy_out(&if path.starts_with('/') { path.clone() } else { format!("{dir}/{path}") }),
|
||||
// Files ride to the chat only from this bot's own folder on the box.
|
||||
Ok(tools::Target { place: tools::Place::Box { dir }, .. }) => match tools::box_path(std::path::Path::new(&dir), &path) {
|
||||
Ok(p) => crate::cloudbox::copy_out(&p),
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
Ok(tools::Target { place: tools::Place::Sealed { root }, .. }) => Ok(tools::resolve(&root, &path)),
|
||||
Ok(tools::Target { place: tools::Place::Local { workdir }, .. }) => Ok(tools::resolve(&workdir, &path)),
|
||||
Ok(_) => Err("這台電腦上的檔案不能直接傳,請先複製到 Bot 的電腦。".to_string()),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
// Secrets (SSH keys, the app's own data folder, .env…) never ride into the chat,
|
||||
// same rule as read_file/edit_file.
|
||||
let local = local.and_then(|p| if tools::is_ignored(&p) || tools::is_ignored(std::path::Path::new(&path)) { Err("這個檔案是機密的,不能傳到聊天。".to_string()) } else { Ok(p) });
|
||||
let (output, ok) = match local.and_then(|p| crate::attach::store(bot_id, &p)) {
|
||||
Ok(a) => {
|
||||
let out = format!("已把「{}」傳到聊天裡({}),使用者點一下就能預覽。", a.name, crate::attach::human(a.bytes));
|
||||
|
|
@ -437,6 +549,8 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
|||
(None, _) if path.starts_with("http://") || path.starts_with("https://") => Ok(std::path::PathBuf::from(&path)),
|
||||
_ => Err("這台電腦上的檔案不能直接打開給使用者看,請先複製到 Bot 的電腦或這台 Mac。".to_string()),
|
||||
};
|
||||
// Secrets never open on the person's screen either.
|
||||
let local = local.and_then(|p| if tools::is_ignored(&p) || tools::is_ignored(std::path::Path::new(&path)) { Err("這個檔案是機密的,不能打開。".to_string()) } else { Ok(p) });
|
||||
let (output, ok) = match local {
|
||||
Ok(p) if p.exists() || path.starts_with("http") => match crate::preview::system_open(&p).status() {
|
||||
Ok(st) if st.success() => (format!("已在使用者的螢幕上打開 {}。", p.display()), true),
|
||||
|
|
@ -625,11 +739,16 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
|||
}
|
||||
// The browser tools use the person's own browser, so always this Mac.
|
||||
// …and so does screen control: it's the person's own screen.
|
||||
let person_device = job.computers.iter().find(|t| t.name == crate::model::THIS_COMPUTER_NAME && matches!(t.place, tools::Place::Device { .. })).cloned();
|
||||
let target = if is_screen {
|
||||
screen_target(&job, &call.args)
|
||||
} else if is_web && device_named(&job, &call.args).is_some() {
|
||||
// The browser on another computer of the account, as named.
|
||||
target_for(&job, &call.args)
|
||||
} else if is_web && call.name.starts_with("browser_") && person_device.is_some() && !names_a_computer(&call.args) {
|
||||
// A brain on a server: the browser is the person's computer's (the one they
|
||||
// can see), unless the server's own is named.
|
||||
Ok(person_device.clone().unwrap())
|
||||
} else if is_web && call.name.starts_with("browser_") && !crate::botbrowser::available() && job.computers.iter().any(|t| matches!(t.place, tools::Place::Device { .. })) {
|
||||
// No browser here (a brain on a server): the 手腳 computer's.
|
||||
job.computers.iter().find(|t| matches!(t.place, tools::Place::Device { .. })).cloned().ok_or_else(|| "找不到這台電腦".to_string())
|
||||
|
|
@ -869,6 +988,23 @@ mod tests {
|
|||
use crate::model::ToolCall;
|
||||
use crate::providers::ProviderKind;
|
||||
|
||||
#[test]
|
||||
fn computer_words_need_word_boundaries() {
|
||||
// "mac" as its own word names this computer; inside longer words (macOS, a device
|
||||
// named Ann inside "anniversary") it must not — that is what made the guard fire on
|
||||
// text the person never aimed at any computer.
|
||||
assert!(finds("用我的 mac 執行", "mac"));
|
||||
assert!(finds("mac 上開檔案", "mac"));
|
||||
assert!(!finds("在 macOS 上開檔案", "mac"));
|
||||
assert!(!finds("telegram 傳給我", "mac"));
|
||||
assert!(finds("我的電腦好慢", "我的電腦"));
|
||||
assert!(!finds("你的虛擬電腦很吵", "bot 的電腦"));
|
||||
assert!(finds("在 bot 的電腦跑", "bot 的電腦"));
|
||||
let t = tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: Default::default() } };
|
||||
assert!(mentions("幫我在 Mac 截圖", &t));
|
||||
assert!(!mentions("在 macOS 的登入機制下,幫 Bot 的電腦截圖", &t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_keeps_tool_pairs_and_recent_messages() {
|
||||
let mut h = vec![];
|
||||
|
|
|
|||
88
src/app.rs
88
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<String> = computers.iter().filter(|t| matches!(t.place, tools::Place::Device { .. })).map(|t| t.name.clone()).collect();
|
||||
tool_specs.extend(web::specs_for(&devices));
|
||||
let here = computers.iter().find(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone()).unwrap_or_else(|| crate::model::THIS_COMPUTER_NAME.into());
|
||||
tool_specs.extend(web::specs_for(&here, &devices));
|
||||
// Screen tools: the bot's own desktop (when it has one) and this Mac.
|
||||
let mut screens = vec![];
|
||||
if crate::cloudbox::available() {
|
||||
screens.push(crate::model::BOT_COMPUTER_NAME.to_string());
|
||||
}
|
||||
if crate::computer::screen_supported() {
|
||||
screens.push(crate::model::THIS_COMPUTER_NAME.to_string());
|
||||
screens.extend(computers.iter().filter(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone()));
|
||||
}
|
||||
// …and the account's other computers (手腳): their own AnyBot looks and clicks there.
|
||||
screens.extend(devices.iter().cloned());
|
||||
|
|
@ -804,9 +805,51 @@ impl App {
|
|||
v.extend(self.state.remote_computers.iter().map(|c| tools::Target { name: c.name.clone(), place: tools::Place::Ssh { target: c.ssh_target.clone(), dir: c.dir.clone() } }));
|
||||
// 裝置連結: other AnyBots of this account that allow it.
|
||||
v.extend(self.linked_devices().into_iter().filter(|d| !v.iter().any(|t| t.name == d.name)).collect::<Vec<_>>());
|
||||
// A brain on a server whose hands are the person's computer: "你的電腦" is that computer
|
||||
// (what the person means by "my Mac"), and the server itself goes by its own name.
|
||||
if !cfg!(target_os = "macos") {
|
||||
if let Some(hand) = crate::remote::my_hands(&self.state.remote.host_id).into_iter().next() {
|
||||
if let Some(i) = v.iter().position(|t| matches!(&t.place, tools::Place::Device { host } if *host == hand)) {
|
||||
for t in v.iter_mut() {
|
||||
if matches!(t.place, tools::Place::Local { .. }) {
|
||||
t.name = crate::model::BRAIN_HOST_NAME.into();
|
||||
}
|
||||
}
|
||||
v[i].name = crate::model::THIS_COMPUTER_NAME.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// 查看電腦: the computers this bot really has, in plain words.
|
||||
fn computers_text(&self, b: &Bot) -> String {
|
||||
self.targets_for(b)
|
||||
.iter()
|
||||
.map(|t| match &t.place {
|
||||
tools::Place::Sealed { .. } | tools::Place::Box { .. } => format!("{}:你自己的電腦,一律可用", t.name),
|
||||
tools::Place::Local { .. } if t.name == crate::model::BRAIN_HOST_NAME => format!("{}:AnyBot 大腦所在的伺服器「{}」(不是使用者的電腦)", t.name, self.state.remote.host_name),
|
||||
tools::Place::Local { .. } => format!(
|
||||
"{}:{}「{}」,{}",
|
||||
t.name,
|
||||
if cfg!(target_os = "macos") { "使用者的這台 Mac" } else { "使用者的這台電腦" },
|
||||
self.state.machine_label,
|
||||
if self.state.local_execution { "已允許" } else { "還沒預先允許(直接用工具,App 會問使用者)" }
|
||||
),
|
||||
tools::Place::Ssh { target, .. } => format!("{}:透過 SSH 連到 {target}", t.name),
|
||||
tools::Place::Device { host } => {
|
||||
let real = crate::remote::peer_name(host);
|
||||
if t.name == crate::model::THIS_COMPUTER_NAME {
|
||||
format!("{}:使用者的電腦「{real}」(大腦透過它的 AnyBot 看畫面、點擊、開瀏覽器和跑指令)", t.name)
|
||||
} else {
|
||||
format!("{}:帳號裡的另一台電腦(裝置連結)", t.name)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Record how a turn ended (success / cancelled / error) and clear its live state.
|
||||
fn settle(&mut self, bot_id: u64, outcome: TurnOutcome) {
|
||||
// 群組聊天: the next member answers after this one finishes well.
|
||||
|
|
@ -900,6 +943,9 @@ impl App {
|
|||
for id in std::mem::take(&mut self.group_next) {
|
||||
if !self.streams.contains_key(&id) {
|
||||
self.start_reply(ctx, id);
|
||||
} else {
|
||||
// Still busy: keep the turn queued instead of dropping it with the take().
|
||||
self.group_next.push(id);
|
||||
}
|
||||
}
|
||||
for (id, texts) in std::mem::take(&mut self.pending_turns) {
|
||||
|
|
@ -908,12 +954,23 @@ impl App {
|
|||
b.messages.push(Message::user(&t));
|
||||
}
|
||||
}
|
||||
if self.streams.contains_key(&id) {
|
||||
// A turn just settled in the same tick we queued this one; starting now would
|
||||
// replace the running Live and let two agent threads write one chat. Retry idle.
|
||||
self.group_next.push(id);
|
||||
} else {
|
||||
self.start_reply(ctx, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_app_action(&mut self, ctx: &egui::Context, bot_id: u64, call: AppCall) {
|
||||
let out = appctl::apply(&mut self.state, bot_id, &call.name, &call.args);
|
||||
let out = if call.name == "list_computers" {
|
||||
let text = self.state.bot(bot_id).cloned().map(|b| self.computers_text(&b)).unwrap_or_default();
|
||||
appctl::Outcome { text, ok: true, wake: vec![], theme_changed: false, page: None }
|
||||
} else {
|
||||
appctl::apply(&mut self.state, bot_id, &call.name, &call.args)
|
||||
};
|
||||
// write_page: remember which page the call made, so its card shows under the reply.
|
||||
if let Some(pid) = out.page {
|
||||
if let Some(c) = self.state.bot_mut(bot_id).and_then(|b| b.messages.iter_mut().rev().take(12).flat_map(|m| m.tool_calls.iter_mut()).find(|c| c.id == call.call_id)) {
|
||||
|
|
@ -1191,9 +1248,11 @@ impl App {
|
|||
StreamEvent::Steered(m) => b.messages.push(m),
|
||||
StreamEvent::NextStep(via) => b.messages.push(Message::assistant("", &via)),
|
||||
StreamEvent::ToolResult(m) => b.messages.push(m),
|
||||
StreamEvent::Compacted(n, summary) => {
|
||||
let n = n.min(b.messages.len());
|
||||
b.messages.splice(..n, [summary]);
|
||||
StreamEvent::Compacted(n, summary, boundary) => {
|
||||
// The model's history can be shorter than the window's chat (group chats
|
||||
// filter); land the cut at the real first-kept message, not the raw index.
|
||||
let cut = b.messages.iter().position(|m| m.at == boundary && m.role == Role::User).unwrap_or(n.min(b.messages.len()));
|
||||
b.messages.splice(..cut, [summary]);
|
||||
}
|
||||
StreamEvent::ToolStatus(call_id, st) => {
|
||||
if let Some(c) = b.messages.iter_mut().rev().flat_map(|m| m.tool_calls.iter_mut()).find(|c| c.id == call_id) {
|
||||
|
|
@ -1203,7 +1262,7 @@ impl App {
|
|||
other => {
|
||||
let Some(m) = b.messages.last_mut() else { continue };
|
||||
match other {
|
||||
StreamEvent::Text(t) => m.text.push_str(&t),
|
||||
StreamEvent::Text(t) => m.text.push_str(&crate::i18n::reply_text(&t)),
|
||||
StreamEvent::Thinking(t) => m.thinking.push_str(&t),
|
||||
StreamEvent::ToolCall(c) => m.tool_calls.push(c),
|
||||
StreamEvent::StepDone(full) => {
|
||||
|
|
@ -2214,7 +2273,7 @@ impl App {
|
|||
crate::icons::label(ui, crate::icons::Icon::Check, &t("輔助使用已打開"), 11.5, p.text3);
|
||||
} else if ui.small_button(t("打開輔助使用")).on_hover_text(t("macOS 要你允許一次,AnyBot 才看得到你的瀏覽器視窗")).clicked() {
|
||||
if !crate::axread::trusted(true) {
|
||||
let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility").spawn();
|
||||
let _ = crate::computer::spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"));
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
|
@ -3190,7 +3249,8 @@ impl App {
|
|||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
let secs = t0.elapsed().as_secs();
|
||||
let msgs = app.state.bot(id).map(|b| b.messages[start..].to_vec()).unwrap_or_default();
|
||||
// The chat can shrink (compaction) between the snapshot and now: slice safely.
|
||||
let msgs = app.state.bot(id).and_then(|b| b.messages.get(start..)).map(|m| m.to_vec()).unwrap_or_default();
|
||||
let clip = |s: &str, n: usize| -> String {
|
||||
let one = s.replace('\n', " ⏎ ");
|
||||
if one.chars().count() > n { format!("{}…", one.chars().take(n).collect::<String>()) } else { one }
|
||||
|
|
@ -3668,17 +3728,23 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
|
|||
tools::Place::Local { workdir } => format!(
|
||||
"- {}:{},起始資料夾 {}{}",
|
||||
t.name,
|
||||
if cfg!(target_os = "macos") { "使用者的這台 Mac" } else if crate::platform::has_desktop() { "使用者的這台 Linux 電腦(快捷鍵用 Ctrl,不是 ⌘)" } else { "AnyBot 所在的伺服器(不是使用者的 Mac;要用使用者的 Mac 請選帳號裡那台電腦的名字)" },
|
||||
if t.name == crate::model::BRAIN_HOST_NAME { "你(大腦)所在的伺服器,不是使用者的電腦;只有使用者明確要在伺服器上做事才用" } else if cfg!(target_os = "macos") { "使用者的這台 Mac" } else if crate::platform::has_desktop() { "使用者的這台 Linux 電腦(快捷鍵用 Ctrl,不是 ⌘)" } else { "AnyBot 所在的伺服器(不是使用者的 Mac;要用使用者的 Mac 請選帳號裡那台電腦的名字)" },
|
||||
workdir.display(),
|
||||
if st.local_execution { "" } else { "(還沒預先允許:直接使用工具即可,App 會跳出視窗問使用者)" }
|
||||
),
|
||||
tools::Place::Ssh { target, .. } => format!("- {}:使用者的另一台電腦(SSH {target})", t.name),
|
||||
tools::Place::Device { host } if t.name == crate::model::THIS_COMPUTER_NAME => format!(
|
||||
"- {}:使用者自己的電腦「{}」(你在伺服器上,透過它的 AnyBot 操作它:檔案、指令、瀏覽器和 computer_use_* 工具,computer=「{}」)。使用者說「我的電腦」「我的筆電」「這台」或提到 Mac,都是指它。",
|
||||
t.name,
|
||||
crate::remote::peer_name(host),
|
||||
t.name
|
||||
),
|
||||
tools::Place::Device { .. } => format!("- {}:使用者帳號裡的另一台電腦(裝置連結;可以用檔案、指令和 computer_use_* 工具,computer=「{}」)", t.name, t.name),
|
||||
})
|
||||
.collect();
|
||||
s.push_str(&format!(
|
||||
"\n\n你可以用工具在電腦上做事。每個工具都有 computer 參數,預設是「{default_computer}」。\n{}\n\
|
||||
- 使用者說在哪台電腦上做(「在我的 Mac 上」「在工作站上」),就把 computer 設成那台;沒說就用預設。\n\
|
||||
- 使用者替你選的電腦是「{default_computer}」:檔案、指令、看畫面、點擊和瀏覽器都在這台做,不要自己換到別台。只有使用者在訊息裡指名另一台(「在我的 Mac 上」「在 Bot 的電腦上」),才把 computer 設成那台。\n\
|
||||
- 先用 list_dir、search、read_file 看清楚,再動手。\n\
|
||||
- 需要權限時(電腦工具還沒打開、要用使用者的電腦、會改變東西的操作),直接呼叫工具,App 會自己跳出視窗請使用者允許;不要叫使用者自己去改設定。被拒絕就換個方式或問使用者。\n\
|
||||
- 跑很久的指令會移到背景,用 read_terminal 追蹤,不要重複執行;它在等輸入時(例如問 y/n),用 write_terminal 回答。\n\
|
||||
|
|
|
|||
|
|
@ -96,6 +96,18 @@ fn start_job(kind: &str, path: &str) -> Option<Arc<Mutex<PageJob>>> {
|
|||
g.done = true;
|
||||
});
|
||||
}
|
||||
"image" => {
|
||||
// Decoding (a 6000×4000 photo, a big SVG) on the UI thread froze the window;
|
||||
// the viewer shows a spinner until this thread is done.
|
||||
std::thread::spawn(move || {
|
||||
let img = preview::decode_image(&path);
|
||||
let mut g = j.lock().unwrap();
|
||||
if let Some(img) = img {
|
||||
g.ready.push(img);
|
||||
}
|
||||
g.done = true;
|
||||
});
|
||||
}
|
||||
"video" => {
|
||||
std::thread::spawn(move || {
|
||||
let out = std::env::temp_dir().join(format!("anybot-poster-{}-{}.png", std::process::id(), crate::model::now_ms()));
|
||||
|
|
@ -258,9 +270,31 @@ impl App {
|
|||
});
|
||||
}
|
||||
Preview::Image => {
|
||||
// The picture is decoded on a worker thread (start_job "image").
|
||||
if fv.image.is_none() {
|
||||
if let Some(job) = fv.job.clone() {
|
||||
let (imgs, done) = {
|
||||
let mut g = job.lock().unwrap();
|
||||
(std::mem::take(&mut g.ready), g.done)
|
||||
};
|
||||
match imgs.into_iter().next() {
|
||||
Some(img) => fv.image = Some(texture(ui.ctx(), "file-view", &img)),
|
||||
None => {
|
||||
ui.add_space(40.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
if done {
|
||||
ui.label(RichText::new(t("這張圖片打不開")).color(p.text3));
|
||||
} else {
|
||||
ui.spinner();
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(80));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fv.image = load_texture(ui.ctx(), &fv.path);
|
||||
}
|
||||
}
|
||||
match &fv.image {
|
||||
Some(tex) => {
|
||||
let avail = vec2(ui.available_width() - 24.0, body_h - 24.0);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -262,14 +262,20 @@ impl App {
|
|||
if self.state.bot(bot).is_none() {
|
||||
return;
|
||||
}
|
||||
// Only files the app itself stored in its attachments folder may ride along;
|
||||
// resolve first, so "../../" paths cannot escape it (the phone can name any path).
|
||||
let root = crate::model::State::path().parent().map(|p| p.join("attachments")).and_then(|p| p.canonicalize().ok());
|
||||
let atts: Vec<crate::attach::Attachment> = attachments
|
||||
.iter()
|
||||
.filter(|p| std::path::Path::new(p).starts_with(crate::model::State::path().parent().unwrap_or(std::path::Path::new("/"))))
|
||||
.filter_map(|p| {
|
||||
let meta = std::fs::metadata(p).ok()?;
|
||||
let file = std::path::Path::new(p).file_name()?.to_string_lossy().to_string();
|
||||
let f = std::path::Path::new(p).canonicalize().ok()?;
|
||||
if !root.as_ref().map(|r| f.starts_with(r) && f.is_file()).unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
let meta = std::fs::metadata(&f).ok()?;
|
||||
let file = f.file_name()?.to_string_lossy().to_string();
|
||||
let name = file.split_once('-').map(|x| x.1.to_string()).unwrap_or(file);
|
||||
Some(crate::attach::Attachment { image: crate::attach::is_image_name(&name), name, path: p.clone(), bytes: meta.len(), on_box: String::new() })
|
||||
Some(crate::attach::Attachment { image: crate::attach::is_image_name(&name), name, path: f.display().to_string(), bytes: meta.len(), on_box: String::new() })
|
||||
})
|
||||
.collect();
|
||||
if let Some(l) = self.streams.get(&bot) {
|
||||
|
|
@ -439,6 +445,14 @@ impl App {
|
|||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if ui.add(egui::Button::new(RichText::new(t("登出帳號")).size(12.5).color(p.danger)).frame(false)).clicked() {
|
||||
let keep_name = self.state.remote.host_name.clone();
|
||||
// Say goodbye to the account server: the machine's registration is
|
||||
// removed, so its key dies with the sign-out.
|
||||
let (url, token) = (self.state.remote.relay_url.clone(), self.state.remote.host_token.clone());
|
||||
if !url.is_empty() && !token.is_empty() {
|
||||
std::thread::spawn(move || {
|
||||
let _ = remote::relay_post(&format!("{url}/relay/host/remove"), &token, &serde_json::json!({}));
|
||||
});
|
||||
}
|
||||
let s = &mut self.state.remote;
|
||||
s.host_id.clear();
|
||||
s.host_token.clear();
|
||||
|
|
@ -720,12 +734,15 @@ impl App {
|
|||
if self.state.remote.host_token.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let has_hands = !remote::my_hands(&self.state.remote.host_id).is_empty();
|
||||
self.rm
|
||||
.peers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(id, _, online)| !id.is_empty() && *online && *id != self.state.remote.host_id)
|
||||
// A brain with hands of its own works on those, not on the account's other brains.
|
||||
.filter(|(id, _, _)| !has_hands || remote::is_hands(id))
|
||||
.map(|(id, name, _)| tools::Target { name: name.clone(), place: tools::Place::Device { host: id.clone() } })
|
||||
.collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -587,10 +587,13 @@ impl App {
|
|||
VaultDialog::Import(mut st) => {
|
||||
if self.vault_import(ctx, &mut st) {
|
||||
next = Some(VaultDialog::Import(st));
|
||||
}
|
||||
} else {
|
||||
// Only when it closed: marking saved every frame reloaded the whole
|
||||
// vault list on every repaint while the import dialog was open.
|
||||
saved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.vault.dialog = next;
|
||||
if saved {
|
||||
self.vault_refresh();
|
||||
|
|
|
|||
|
|
@ -528,7 +528,7 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome
|
|||
if changed.is_empty() {
|
||||
return err("沒有要改的項目".into());
|
||||
}
|
||||
ok(format!("已更新「{}」的{}(現在:{} · {})", b.name, changed.join("、"), b.provider.label(), b.model))
|
||||
ok(format!("已更新「{}」的{}(現在:{} · {},預設電腦「{}」)", b.name, changed.join("、"), b.provider.label(), b.model, b.default_computer))
|
||||
}
|
||||
"message_bot" => {
|
||||
let Some(n) = s("bot") else { return err("要指定 Bot".into()) };
|
||||
|
|
@ -545,7 +545,7 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome
|
|||
"list_computers" => {
|
||||
let mut lines = vec![
|
||||
format!("{}:你自己的電腦(獨立的資料夾),一律可用", model::BOT_COMPUTER_NAME),
|
||||
format!("{}:這台 Mac,{}", model::THIS_COMPUTER_NAME, if state.local_execution { "已允許" } else { "使用者還沒在設定裡允許(設定 → 電腦 → 在這台電腦上執行)" }),
|
||||
format!("{}:{},{}", model::THIS_COMPUTER_NAME, if cfg!(target_os = "macos") { "這台 Mac" } else { "這台電腦" }, if state.local_execution { "已允許" } else { "使用者還沒在設定裡允許(設定 → 電腦 → 在這台電腦上執行)" }),
|
||||
];
|
||||
lines.extend(state.remote_computers.iter().map(|c| format!("{}:透過 SSH 連到 {}", c.name, c.ssh_target)));
|
||||
ok(lines.join("\n"))
|
||||
|
|
@ -554,13 +554,20 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome
|
|||
let mut out = Outcome { text: String::new(), ok: true, wake: vec![], theme_changed: false, page: None };
|
||||
let mut changed = vec![];
|
||||
if let Some(t) = s("theme") {
|
||||
state.theme = match t.as_str() {
|
||||
"dark" => Theme::Dark,
|
||||
"system" => Theme::System,
|
||||
_ => Theme::Light,
|
||||
// An unknown name used to mean Light with no word about it.
|
||||
let (th, known) = match t.as_str() {
|
||||
"dark" => (Theme::Dark, true),
|
||||
"system" => (Theme::System, true),
|
||||
"light" => (Theme::Light, true),
|
||||
_ => (state.theme, false),
|
||||
};
|
||||
if known {
|
||||
state.theme = th;
|
||||
out.theme_changed = true;
|
||||
changed.push(format!("主題 → {t}"));
|
||||
} else {
|
||||
out.text.push_str(&format!("主題「{t}」不存在(可用 dark、light、system)。\n"));
|
||||
}
|
||||
}
|
||||
if let Some(a) = s("accent") {
|
||||
state.accent = if a == "bot_color" { Accent::BotColor } else { Accent::Black };
|
||||
|
|
|
|||
|
|
@ -304,7 +304,7 @@ pub fn read_person_browser(exe: &str, max_chars: usize) -> Result<Page, String>
|
|||
return Err("只支援 macOS".into());
|
||||
}
|
||||
if !trusted(true) {
|
||||
let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility").spawn();
|
||||
let _ = crate::computer::spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"));
|
||||
return Err("macOS 還沒讓 AnyBot 使用「輔助使用」,所以看不到你的瀏覽器。我已經打開「系統設定 → 隱私權與安全性 → 輔助使用」:把 AnyBot(或「終端機」)打開,然後再叫我試一次。只要開一次。".into());
|
||||
}
|
||||
let ps = std::process::Command::new("ps").args(["-axo", "pid=,args="]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default();
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ pub fn engine() -> Option<(&'static str, &'static str)> {
|
|||
}
|
||||
|
||||
pub fn available() -> bool {
|
||||
if using_box() {
|
||||
return true;
|
||||
}
|
||||
engine().is_some()
|
||||
}
|
||||
|
||||
|
|
@ -132,6 +135,9 @@ pub fn match_profile(list: &[String], want: &str) -> Option<String> {
|
|||
|
||||
/// Which profile a tool call means: the one named, or the current one.
|
||||
pub fn resolve(requested: Option<&str>) -> Result<String, String> {
|
||||
if using_box() {
|
||||
return Ok(DEFAULT_PROFILE.to_string());
|
||||
}
|
||||
match requested.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
None => Ok(current()),
|
||||
Some(r) => {
|
||||
|
|
@ -231,8 +237,35 @@ pub fn encode_component(s: &str) -> String {
|
|||
.collect()
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Browser steps on this thread go to Bot 的電腦's Chromium (its box), not this computer's.
|
||||
static IN_BOX: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Run `f` with the browser steps on Bot 的電腦's own Chromium.
|
||||
pub fn in_box<T>(f: impl FnOnce() -> T) -> T {
|
||||
IN_BOX.with(|b| b.set(true));
|
||||
let r = f();
|
||||
IN_BOX.with(|b| b.set(false));
|
||||
r
|
||||
}
|
||||
|
||||
pub fn using_box() -> bool {
|
||||
IN_BOX.with(|b| b.get())
|
||||
}
|
||||
|
||||
/// Start this profile's window if it isn't running. Returns (browser name, port, just started).
|
||||
pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u16, bool), String> {
|
||||
if using_box() {
|
||||
let fresh = !crate::cloudbox::browser_running();
|
||||
let port = crate::cloudbox::browser_port()?;
|
||||
if fresh {
|
||||
if let Some(u) = first_url {
|
||||
let _ = local().put(&endpoint(port, &format!("/json/new?{}", encode_component(u)))).send_empty();
|
||||
}
|
||||
}
|
||||
return Ok(("Bot 的電腦的 Chromium", port, fresh && first_url.is_some()));
|
||||
}
|
||||
let Some((name, bin)) = engine() else { return Err("這台電腦沒有 Chrome、Brave、Edge 或 Chromium,沒辦法開 AnyBot 自己的瀏覽器。".into()) };
|
||||
if profile == PERSON_PROFILE {
|
||||
return Err("「你的瀏覽器」是你自己的視窗,AnyBot 不會另外開。".into());
|
||||
|
|
@ -245,13 +278,20 @@ pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u
|
|||
let _ = std::fs::remove_file(dir.join(ACTIVE_PORT_FILE));
|
||||
// 「預設」 keeps its fixed port; other profiles let Chrome pick a free one and read it back.
|
||||
let launch_port = if profile == DEFAULT_PROFILE { default_port() } else { 0 };
|
||||
// Defense in depth: this becomes a Chrome launch argument. Every caller normalizes to a
|
||||
// full URL first; a bare "-…" (a Chrome flag) must never get here from a tool argument.
|
||||
let url = match first_url {
|
||||
Some(u) if u.starts_with("http://") || u.starts_with("https://") => u,
|
||||
Some(_) => return Err("第一個網址必須是 http(s) 網址".into()),
|
||||
None => "about:blank",
|
||||
};
|
||||
let mut c = Command::new(bin);
|
||||
c.arg(format!("--user-data-dir={}", dir.display()))
|
||||
.arg(format!("--remote-debugging-port={launch_port}"))
|
||||
.arg("--remote-allow-origins=http://127.0.0.1")
|
||||
.arg("--no-first-run")
|
||||
.arg("--no-default-browser-check")
|
||||
.arg(first_url.unwrap_or("about:blank"))
|
||||
.arg(url)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
|
|
|
|||
|
|
@ -143,6 +143,12 @@ fn agent(timeout: u64) -> ureq::Agent {
|
|||
ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(timeout))).http_status_as_error(false).build().into()
|
||||
}
|
||||
|
||||
/// Seconds to wait after a 429 ("rate:N" from get_json), bounded so a bogus value can't park
|
||||
/// the channel forever.
|
||||
fn rate_secs(e: &str) -> u64 {
|
||||
e.split(':').nth(1).and_then(|s| s.parse().ok()).unwrap_or(10).clamp(1, 300)
|
||||
}
|
||||
|
||||
fn get_json(url: &str, auth: Option<&str>, timeout: u64) -> Result<Value, String> {
|
||||
let mut r = agent(timeout).get(url);
|
||||
if let Some(a) = auth {
|
||||
|
|
@ -152,7 +158,10 @@ fn get_json(url: &str, auth: Option<&str>, timeout: u64) -> Result<Value, String
|
|||
let status = resp.status().as_u16();
|
||||
let text = resp.body_mut().read_to_string().unwrap_or_default();
|
||||
if status == 429 {
|
||||
return Err("rate".into());
|
||||
// Carry the server's own "wait N seconds" (Telegram and Slack both send it); a fixed
|
||||
// 5-second retry used to hammer the API and stay rate-limited.
|
||||
let ra = serde_json::from_str::<Value>(&text).ok().and_then(|v| v["parameters"]["retry_after"].as_u64()).unwrap_or(10);
|
||||
return Err(format!("rate:{ra}"));
|
||||
}
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(format!("HTTP {status}:{}", text.chars().take(160).collect::<String>()));
|
||||
|
|
@ -457,8 +466,9 @@ fn poll(c: Channel, gen: u64, tx: Sender<Incoming>, repaint: Arc<dyn Fn() + Send
|
|||
match get_json(&url, Some(&auth), 20) {
|
||||
Ok(v) if v["ok"].as_bool() == Some(true) => {
|
||||
if cursor.is_empty() {
|
||||
// Start from now: older messages aren't answered.
|
||||
cursor = v["messages"][0]["ts"].as_str().unwrap_or("0").to_string();
|
||||
// Start from now: older messages aren't answered. (An empty channel
|
||||
// has no ts to stand on — "0" made the next round replay history.)
|
||||
cursor = v["messages"].get(0).and_then(|m| m["ts"].as_str()).map(String::from).unwrap_or_else(|| chrono::Utc::now().timestamp().to_string());
|
||||
set_status(c.id, true, "已連上,等待新訊息", 0);
|
||||
} else {
|
||||
let (next, items) = slack_new(&v, &cursor, &own);
|
||||
|
|
@ -469,7 +479,7 @@ fn poll(c: Channel, gen: u64, tx: Sender<Incoming>, repaint: Arc<dyn Fn() + Send
|
|||
}
|
||||
}
|
||||
Ok(v) => set_status(c.id, false, &format!("Slack 說:{}", v["error"].as_str().unwrap_or("不明錯誤")), 0),
|
||||
Err(e) if e == "rate" => std::thread::sleep(Duration::from_secs(20)),
|
||||
Err(e) if e.starts_with("rate") => std::thread::sleep(Duration::from_secs(rate_secs(&e))),
|
||||
Err(e) => set_status(c.id, false, &e, 0),
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(POLL_SECS));
|
||||
|
|
@ -497,7 +507,7 @@ fn poll(c: Channel, gen: u64, tx: Sender<Incoming>, repaint: Arc<dyn Fn() + Send
|
|||
}
|
||||
}
|
||||
}
|
||||
Err(e) if e == "rate" => std::thread::sleep(Duration::from_secs(10)),
|
||||
Err(e) if e.starts_with("rate") => std::thread::sleep(Duration::from_secs(rate_secs(&e))),
|
||||
Err(e) => set_status(c.id, false, &e, 0),
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(POLL_SECS));
|
||||
|
|
@ -530,7 +540,7 @@ fn poll(c: Channel, gen: u64, tx: Sender<Incoming>, repaint: Arc<dyn Fn() + Send
|
|||
}
|
||||
}
|
||||
}
|
||||
Err(e) if e == "rate" => std::thread::sleep(Duration::from_secs(20)),
|
||||
Err(e) if e.starts_with("rate") => std::thread::sleep(Duration::from_secs(rate_secs(&e))),
|
||||
Err(e) => set_status(c.id, false, &e, 0),
|
||||
}
|
||||
}
|
||||
|
|
@ -655,12 +665,20 @@ fn poll(c: Channel, gen: u64, tx: Sender<Incoming>, repaint: Arc<dyn Fn() + Send
|
|||
}
|
||||
}
|
||||
Kind::Telegram => {
|
||||
// Skip what's waiting from before: start after the latest update.
|
||||
let mut offset = get_json(&format!("{}/bot{token}/getUpdates?offset=-1", tg()), None, 15)
|
||||
.ok()
|
||||
.and_then(|v| v["result"][0]["update_id"].as_i64())
|
||||
.map(|u| u + 1)
|
||||
.unwrap_or(0);
|
||||
// Skip what's waiting from before: start after the latest update. If this acknowledge
|
||||
// fails, offset 0 would replay the whole backlog on the first poll — retry it.
|
||||
let mut offset = loop {
|
||||
match get_json(&format!("{}/bot{token}/getUpdates?offset=-1", tg()), None, 15) {
|
||||
Ok(v) => break v["result"].as_array().and_then(|a| a.last()).and_then(|u| u["update_id"].as_i64()).map(|u| u + 1).unwrap_or(1),
|
||||
Err(e) => {
|
||||
set_status(c.id, false, &format!("正在連上 Telegram:{e}"), 0);
|
||||
if !alive(gen) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(rate_secs(&e)));
|
||||
}
|
||||
}
|
||||
};
|
||||
set_status(c.id, true, "已連上,等待新訊息", 0);
|
||||
while alive(gen) {
|
||||
let url = format!("{}/bot{token}/getUpdates?offset={offset}&timeout={TELEGRAM_WAIT_SECS}", tg());
|
||||
|
|
@ -677,7 +695,8 @@ fn poll(c: Channel, gen: u64, tx: Sender<Incoming>, repaint: Arc<dyn Fn() + Send
|
|||
}
|
||||
Err(e) => {
|
||||
set_status(c.id, false, &e, 0);
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
// A 429 says how long to back off; hammering every 5 s keeps it limited.
|
||||
std::thread::sleep(Duration::from_secs(if e.starts_with("rate") { rate_secs(&e) } else { 5 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
173
src/cloudbox.rs
173
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<Config> {
|
|||
C.get_or_init(|| Mutex::new(load_config()))
|
||||
}
|
||||
|
||||
/// The box's VNC password: made once, kept across restarts (the container is started with it,
|
||||
/// the viewer opens with it). x11vnc ran with no password at all before — any program inside
|
||||
/// the box (a page the bot opened, for instance) could watch and drive the desktop.
|
||||
pub fn vnc_password() -> String {
|
||||
{
|
||||
let c = config().lock().unwrap();
|
||||
if !c.vnc_password.is_empty() {
|
||||
return c.vnc_password.clone();
|
||||
}
|
||||
}
|
||||
let pw = crate::httpd::random_hex(10);
|
||||
config().lock().unwrap().vnc_password.clone_from(&pw);
|
||||
save_config(&config().lock().unwrap());
|
||||
pw
|
||||
}
|
||||
|
||||
static BUSY: AtomicUsize = AtomicUsize::new(0);
|
||||
/// The person took control in the viewer: the bot's screen actions wait.
|
||||
pub static HELD_BY_PERSON: AtomicBool = AtomicBool::new(false);
|
||||
|
|
@ -378,12 +398,10 @@ fn docker_prefix() -> String {
|
|||
|
||||
/// Run docker; Ok(stdout) or Err(stderr).
|
||||
fn dk(args: &[&str]) -> Result<String, String> {
|
||||
let out = docker().args(args).output().map_err(|e| format!("找不到 docker:{e}"))?;
|
||||
if out.status.success() {
|
||||
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||||
}
|
||||
// Never block forever on the daemon: a hung docker daemon used to pile up a new stuck
|
||||
// thread every 20 s from the refresh loop. (docker's own CLI has no default timeout.)
|
||||
let out = dk_timeout(args, 120_000)?;
|
||||
Ok(String::from_utf8_lossy(&out).trim().to_string())
|
||||
}
|
||||
|
||||
fn dk_timeout(args: &[&str], ms: u64) -> Result<Vec<u8>, String> {
|
||||
|
|
@ -493,7 +511,8 @@ fn build_image(pull: bool) -> Result<(), String> {
|
|||
}
|
||||
let d = dir.display().to_string();
|
||||
args.push(&d);
|
||||
let r = dk(&args);
|
||||
// Builds take minutes (a cold one more than dk's short timeout allows).
|
||||
let r = dk_timeout(&args, 20 * 60_000).map(|_| String::new());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
r.map(|_| log("映像檔完成。")).map_err(|e| format!("建立映像檔失敗:{}", tail(&e, 600)))
|
||||
}
|
||||
|
|
@ -524,9 +543,10 @@ fn create_container() -> Result<(), String> {
|
|||
let publish = format!("127.0.0.1:{port}:{NOVNC_PORT}");
|
||||
let vol = format!("{VOLUME}:{HOME}");
|
||||
let env = format!("SCREEN_SIZE={SCREEN}");
|
||||
let vnc = format!("VNC_PASSWORD={}", vnc_password());
|
||||
log("正在開一台新的電腦…");
|
||||
// host.docker.internal → this Mac (for 網路流量改走你的電腦; Docker Desktop and OrbStack already have it).
|
||||
dk(&["run", "-d", "--name", CONTAINER, "--hostname", "bot-computer", "--shm-size", "1g", "--add-host", "host.docker.internal:host-gateway", "-p", &publish, "-v", &vol, "-e", &env, "--label", "anybot.box=1", IMAGE]).map_err(|e| format!("開不了電腦:{}", tail(&e, 400)))?;
|
||||
dk(&["run", "-d", "--name", CONTAINER, "--hostname", "bot-computer", "--shm-size", "1g", "--pids-limit", "4096", "--memory", "2g", "--cpus", "2", "--add-host", "host.docker.internal:host-gateway", "-p", &publish, "-v", &vol, "-e", &env, "-e", &vnc, "--label", "anybot.box=1", IMAGE]).map_err(|e| format!("開不了電腦:{}", tail(&e, 400)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -541,6 +561,73 @@ fn wait_ready(ms: u64) -> Result<(), String> {
|
|||
Err("電腦開機逾時".into())
|
||||
}
|
||||
|
||||
// ---------- the box's own browser ----------
|
||||
// Browser steps on Bot 的電腦 drive the Chromium on its desktop (the person can watch it in the
|
||||
// box's window). Its DevTools port stays inside the box; a local port reaches it through
|
||||
// `docker exec` (no published port, so it also works with a Docker host elsewhere).
|
||||
|
||||
const BOX_CDP_PORT: u16 = 9222;
|
||||
|
||||
fn box_cdp_alive(local: u16) -> bool {
|
||||
crate::botbrowser::local().get(&format!("http://127.0.0.1:{local}/json/version")).call().map(|r| r.status().as_u16() == 200).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// A local port that reaches the box's DevTools port.
|
||||
fn box_cdp_forward() -> Result<u16, String> {
|
||||
static PORT: Mutex<u16> = Mutex::new(0);
|
||||
let mut p = PORT.lock().unwrap();
|
||||
if *p != 0 {
|
||||
return Ok(*p);
|
||||
}
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
|
||||
let port = l.local_addr().map_err(|e| e.to_string())?.port();
|
||||
std::thread::spawn(move || {
|
||||
for conn in l.incoming().flatten() {
|
||||
std::thread::spawn(move || {
|
||||
let script = format!("exec 3<>/dev/tcp/127.0.0.1/{BOX_CDP_PORT} || exit 1; cat <&3 & cat >&3; kill $! 2>/dev/null");
|
||||
let Ok(mut child) = docker().args(["exec", "-i", CONTAINER, "bash", "-c", &script]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { return };
|
||||
let (Some(mut cin), Some(mut cout)) = (child.stdin.take(), child.stdout.take()) else { return };
|
||||
let (Ok(mut r), Ok(mut w)) = (conn.try_clone(), conn.try_clone()) else { return };
|
||||
let up = std::thread::spawn(move || {
|
||||
let _ = std::io::copy(&mut r, &mut cin);
|
||||
});
|
||||
let _ = std::io::copy(&mut cout, &mut w);
|
||||
let _ = conn.shutdown(std::net::Shutdown::Both);
|
||||
let _ = child.kill();
|
||||
let _ = up.join();
|
||||
let _ = child.wait();
|
||||
});
|
||||
}
|
||||
});
|
||||
*p = port;
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
/// Is the box's Chromium answering already?
|
||||
pub fn browser_running() -> bool {
|
||||
box_cdp_forward().map(box_cdp_alive).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The box's Chromium, started on its desktop when it isn't running. Gives the local port.
|
||||
pub fn browser_port() -> Result<u16, String> {
|
||||
ensure_running()?;
|
||||
touch();
|
||||
let port = box_cdp_forward()?;
|
||||
if box_cdp_alive(port) {
|
||||
return Ok(port);
|
||||
}
|
||||
let dbg = format!("--remote-debugging-port={BOX_CDP_PORT}");
|
||||
dk(&["exec", "-d", "-u", "bot", "-e", "DISPLAY=:1", "-e", "HOME=/home/bot", CONTAINER, "chromium", &dbg, "--remote-allow-origins=*", "--user-data-dir=/home/bot/.config/anybot-browser", "about:blank"]).map_err(|e| format!("Bot 的電腦打不開瀏覽器:{e}"))?;
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < Duration::from_secs(20) {
|
||||
if box_cdp_alive(port) {
|
||||
return Ok(port);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
}
|
||||
Err("Bot 的電腦的瀏覽器沒有及時打開,請再試一次。".into())
|
||||
}
|
||||
|
||||
static START_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Make sure the box is up, waking or creating it as needed. Blocks until it's ready.
|
||||
|
|
@ -657,6 +744,15 @@ pub fn save_snapshot() -> Result<u64, String> {
|
|||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Drain stderr: tar's file-changed warnings pile up fast, and a full 64 KB pipe stalls
|
||||
// tar (and the snapshot) until the kill timer fires.
|
||||
let mut se = child.stderr.take().ok_or("沒有輸出")?;
|
||||
let err_t = std::thread::spawn(move || {
|
||||
use std::io::Read;
|
||||
let mut v = vec![];
|
||||
let _ = se.read_to_end(&mut v);
|
||||
v
|
||||
});
|
||||
let start = Instant::now();
|
||||
let ok = loop {
|
||||
if let Ok(Some(st)) = child.try_wait() {
|
||||
|
|
@ -668,6 +764,7 @@ pub fn save_snapshot() -> Result<u64, String> {
|
|||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
};
|
||||
let _ = err_t.join();
|
||||
if !ok {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err("快照沒有存成功".into());
|
||||
|
|
@ -929,6 +1026,11 @@ pub fn set_secret(name: &str, value: &str) -> Result<(), String> {
|
|||
if value.is_empty() {
|
||||
return Err("請填入值".into());
|
||||
}
|
||||
// docker's --env-file keeps values on one line; a value with newlines used to be written
|
||||
// with literal "\n" escapes and arrived broken (multi-line PEM keys) — say so up front.
|
||||
if value.contains(['\n', '\r']) {
|
||||
return Err("值裡不能有換行(多行金鑰請壓成一行,例如 -----BEGIN KEY-----…-----END KEY-----)".into());
|
||||
}
|
||||
if value.len() > SECRET_MAX_VALUE_BYTES {
|
||||
return Err(format!("值太長了(最多 {} KB)", SECRET_MAX_VALUE_BYTES / 1024));
|
||||
}
|
||||
|
|
@ -955,22 +1057,49 @@ pub fn bot_dir(bot_id: u64) -> String {
|
|||
}
|
||||
|
||||
/// A local shell string that runs `cmd` in the box as the bot user, in `dir`, with the secrets.
|
||||
pub fn exec_wrapper(dir: &str, cmd: &str, interactive: bool) -> String {
|
||||
pub fn exec_wrapper(dir: &str, cmd: &str, interactive: bool, job: &str) -> String {
|
||||
use crate::tools::sh_quote;
|
||||
let inner = format!("mkdir -p {d} && cd {d} && {cmd}", d = sh_quote(dir));
|
||||
// The job tag rides in the command line so the app can kill the in-container process later
|
||||
// (killing the local docker client alone leaves the program running in the box).
|
||||
let tagged = if job.is_empty() { cmd.to_string() } else { format!("export ANYBOT_JOB={job}; {cmd}") };
|
||||
let inner = format!("mkdir -p {d} && cd {d} && {tagged}", d = sh_quote(dir));
|
||||
let env = env_file_path();
|
||||
let env_arg = if env.exists() { format!(" --env-file {}", sh_quote(&env.display().to_string())) } else { String::new() };
|
||||
format!("{} exec{} -u bot -e DISPLAY={DISPLAY} -e HOME={HOME}{}{env_arg} {CONTAINER} bash -lc {}", docker_prefix(), if interactive { " -i" } else { "" }, egress_env(), sh_quote(&inner))
|
||||
}
|
||||
|
||||
/// An argv that kills the box process carrying this job tag (and its children).
|
||||
pub fn exec_kill_argv(job: &str) -> Vec<String> {
|
||||
let mut argv = vec!["docker".to_string()];
|
||||
let host = docker_host();
|
||||
if !host.is_empty() {
|
||||
argv.extend(["-H".to_string(), host]);
|
||||
}
|
||||
argv.extend([
|
||||
"exec".to_string(),
|
||||
CONTAINER.to_string(),
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
format!("for p in $(pgrep -f 'export ANYBOT_JOB={job}'); do pkill -9 -P $p; pkill -9 -P $p; kill -9 $p 2>/dev/null; done; true"),
|
||||
]);
|
||||
argv
|
||||
}
|
||||
|
||||
/// Put a file into a bot's folder on the box (only when the box is already running).
|
||||
pub fn copy_in(local: &str, bot_id: u64, name: &str) -> Result<String, String> {
|
||||
if !run_state().is_running() {
|
||||
return Err("Bot 的電腦沒有在執行".into());
|
||||
}
|
||||
// The name arrives from uploads: keep only its file part, so no `../` can steer the
|
||||
// root-run `docker cp` out of this bot's attachments folder.
|
||||
let safe = std::path::Path::new(name)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().chars().filter(|c| *c != '\0').collect::<String>())
|
||||
.filter(|n| !n.is_empty() && n != "." && n != "..")
|
||||
.ok_or_else(|| "檔案名稱無效".to_string())?;
|
||||
let dir = format!("{}/附件", bot_dir(bot_id));
|
||||
dk(&["exec", "-u", "bot", CONTAINER, "mkdir", "-p", &dir])?;
|
||||
let dst = format!("{dir}/{name}");
|
||||
let dst = format!("{dir}/{safe}");
|
||||
dk(&["cp", local, &format!("{CONTAINER}:{dst}")])?;
|
||||
let _ = dk(&["exec", CONTAINER, "chown", "bot:bot", &dst]);
|
||||
Ok(dst)
|
||||
|
|
@ -1107,11 +1236,20 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option<String>), S
|
|||
Ok((format!("{}", (x * s.0).round() as i64), format!("{}", (y * s.1).round() as i64)))
|
||||
};
|
||||
let with_mods = |mods: &[String], f: &dyn Fn() -> Result<String, String>| -> Result<String, String> {
|
||||
// Track what went down: a failed keydown must release the keys already held, or a
|
||||
// stuck modifier poisons every later click and keystroke in the box.
|
||||
let mut pressed: Vec<&String> = vec![];
|
||||
for m in mods {
|
||||
xdo(&["keydown", m])?;
|
||||
if let Err(e) = xdo(&["keydown", m]) {
|
||||
for p in pressed.iter().rev() {
|
||||
let _ = xdo(&["keyup", p]);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
pressed.push(m);
|
||||
}
|
||||
let r = f();
|
||||
for m in mods.iter().rev() {
|
||||
for m in pressed.iter().rev() {
|
||||
let _ = xdo(&["keyup", m]);
|
||||
}
|
||||
r
|
||||
|
|
@ -1170,7 +1308,9 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option<String>), S
|
|||
}
|
||||
"computer_use_drag" => {
|
||||
let s = scale()?;
|
||||
let path: Vec<(String, String)> = v["path"].as_array().map(|a| a.iter().filter_map(|p| pt(p, s).ok()).collect()).unwrap_or_default();
|
||||
// A point that fails to parse is an error, not a silently dropped waypoint.
|
||||
let raw = v["path"].as_array().ok_or("拖曳要給 path:一個點陣,每個點有數字 x 和 y(用截圖上的座標)")?;
|
||||
let path: Vec<(String, String)> = raw.iter().map(|p| pt(p, s)).collect::<Result<_, _>>()?;
|
||||
if path.len() < 2 {
|
||||
return Err("拖曳至少要兩個點(每個點要有數字 x 和 y,用截圖上的座標)".into());
|
||||
}
|
||||
|
|
@ -1307,7 +1447,8 @@ pub fn viewer_url(control: bool) -> Result<String, String> {
|
|||
} else {
|
||||
port
|
||||
};
|
||||
Ok(format!("http://127.0.0.1:{local_port}/vnc.html?autoconnect=1&resize=scale&reconnect=1&show_dot=1{}", if control { "" } else { "&view_only=1" }))
|
||||
// noVNC signs in with the box's VNC password (x11vnc refuses the keyless login now).
|
||||
Ok(format!("http://127.0.0.1:{local_port}/vnc.html?autoconnect=1&resize=scale&reconnect=1&show_dot=1&password={}{}", vnc_password(), if control { "" } else { "&view_only=1" }))
|
||||
}
|
||||
|
||||
pub fn shutdown() {
|
||||
|
|
@ -1457,7 +1598,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn wrapper_runs_as_the_bot_in_its_folder() {
|
||||
let w = exec_wrapper("/home/bot/bot-3", "ls -la", false);
|
||||
let w = exec_wrapper("/home/bot/bot-3", "ls -la", false, "");
|
||||
assert!(w.contains("exec -u bot"));
|
||||
assert!(w.contains(CONTAINER));
|
||||
assert!(w.contains("/home/bot/bot-3"));
|
||||
|
|
|
|||
|
|
@ -18,6 +18,16 @@ pub const NAMES: [&str; 12] = ["computer_use_screenshot", "computer_use_click",
|
|||
pub static HELD_BY_PERSON: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// When the bot last moved the mouse or typed on this Mac (for the floating bar), and who.
|
||||
pub static LAST_ACTION: Mutex<Option<(std::time::Instant, String)>> = Mutex::new(None);
|
||||
|
||||
/// Spawn a short-lived helper (`open`, a player, …) and reap it in the background:
|
||||
/// spawn-without-wait left one zombie per call for the app's whole life.
|
||||
pub fn spawn_reaped(c: &mut std::process::Command) -> std::io::Result<()> {
|
||||
let mut child = c.spawn()?;
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
/// Longest screenshot edge sent to the model (Gct).
|
||||
pub const MAX_LONG_EDGE: u32 = 1024;
|
||||
/// Shortest edge never goes below this (Hs).
|
||||
|
|
@ -367,7 +377,7 @@ pub fn run(name: &str, args: &str) -> (String, bool, Option<String>) {
|
|||
/// Same, remembering which bot acted (for the 「正在操作你的電腦」 bar).
|
||||
pub fn run_by(name: &str, args: &str, who: &str) -> (String, bool, Option<String>) {
|
||||
if name != "computer_use_screenshot" && name != "computer_use_check_permissions" {
|
||||
*LAST_ACTION.lock().unwrap() = Some((std::time::Instant::now(), who.to_string()));
|
||||
*LAST_ACTION.lock().unwrap_or_else(|e| e.into_inner()) = Some((std::time::Instant::now(), who.to_string()));
|
||||
}
|
||||
let r = run_inner(name, &args_of(args));
|
||||
match r {
|
||||
|
|
@ -456,10 +466,10 @@ fn run_inner(name: &str, v: &Value) -> Result<(String, Option<String>), String>
|
|||
return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作;可以先截圖看看。".into());
|
||||
}
|
||||
if cfg!(target_os = "macos") && !crate::axread::trusted(true) {
|
||||
let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility").spawn();
|
||||
let _ = spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"));
|
||||
return Err("macOS 還沒讓 AnyBot 使用「輔助使用」,所以不能動滑鼠和鍵盤。我已經打開「系統設定 → 隱私權與安全性 → 輔助使用」:把 AnyBot(或「終端機」)打開,再叫我試一次。只要開一次。".into());
|
||||
}
|
||||
let scale = || SCALE.lock().unwrap().ok_or_else(|| "請先用 computer_use_screenshot 看一下畫面,座標要用截圖上的位置。".to_string());
|
||||
let scale = || SCALE.lock().unwrap_or_else(|e| e.into_inner()).ok_or_else(|| "請先用 computer_use_screenshot 看一下畫面,座標要用截圖上的位置。".to_string());
|
||||
let pt = |v: &Value, s: (f64, f64)| -> Result<(f64, f64), String> {
|
||||
let (Some(x), Some(y)) = (v["x"].as_f64(), v["y"].as_f64()) else { return Err("請給 x 和 y".into()) };
|
||||
Ok(to_screen(x, y, s))
|
||||
|
|
@ -500,7 +510,10 @@ fn run_inner(name: &str, v: &Value) -> Result<(String, Option<String>), String>
|
|||
}
|
||||
"computer_use_drag" => {
|
||||
let s = scale()?;
|
||||
let path: Vec<(f64, f64)> = v["path"].as_array().map(|a| a.iter().filter_map(|p| pt(p, s).ok()).collect()).unwrap_or_default();
|
||||
// Every point must be valid: points that failed to parse used to be dropped in
|
||||
// silence, so the drag ran on fewer points and reported success at the wrong place.
|
||||
let raw = v["path"].as_array().ok_or("拖曳要給 path:一個點陣,每個點有數字 x 和 y(用截圖上的座標)")?;
|
||||
let path: Vec<(f64, f64)> = raw.iter().map(|p| pt(p, s)).collect::<Result<_, _>>()?;
|
||||
if path.len() < 2 {
|
||||
return Err("拖曳至少要兩個點(每個點要有數字 x 和 y,用截圖上的座標)".into());
|
||||
}
|
||||
|
|
@ -559,7 +572,7 @@ pub fn target_app(name: &str, args: &str) -> Option<String> {
|
|||
if run_text_mode_name(name, &v).is_some() {
|
||||
return crate::desktop::last_app();
|
||||
}
|
||||
let s = (*SCALE.lock().unwrap())?;
|
||||
let s = (*SCALE.lock().unwrap_or_else(|e| e.into_inner()))?;
|
||||
let point = match name {
|
||||
"computer_use_click" | "computer_use_mouse_move" | "computer_use_scroll" | "computer_use_mouse_button" if !v["x"].is_null() => Some(to_screen(v["x"].as_f64()?, v["y"].as_f64()?, s)),
|
||||
"computer_use_drag" => v["path"].as_array().and_then(|a| a.first()).and_then(|p| Some(to_screen(p["x"].as_f64()?, p["y"].as_f64()?, s))),
|
||||
|
|
@ -620,7 +633,7 @@ pub fn app_at_point(x: f64, y: f64) -> Option<String> {
|
|||
|
||||
fn screenshot() -> Result<(String, String), String> {
|
||||
if !mac::screen_capture_allowed() {
|
||||
let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture").spawn();
|
||||
let _ = spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"));
|
||||
return Err("macOS 還沒讓 AnyBot 錄製螢幕,所以看不到畫面。我已經打開「系統設定 → 隱私權與安全性 → 螢幕與系統錄音」:把 AnyBot(或「終端機」)打開,再叫我試一次。只要開一次。".into());
|
||||
}
|
||||
let img = capture_screen()?;
|
||||
|
|
@ -630,7 +643,7 @@ fn screenshot() -> Result<(String, String), String> {
|
|||
let jpeg = encode_jpeg(&img).ok_or("截圖太大了,沒辦法送出")?;
|
||||
let (pw, ph) = mac::screen_points();
|
||||
let scale = (if fw > 0 { pw / fw as f64 } else { 1.0 }, if fh > 0 { ph / fh as f64 } else { 1.0 });
|
||||
*SCALE.lock().unwrap() = Some(scale);
|
||||
*SCALE.lock().unwrap_or_else(|e| e.into_inner()) = Some(scale);
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&jpeg);
|
||||
Ok((format!("你的螢幕截圖({fw}×{fh})。之後的座標請用這張圖上的像素位置。"), b64))
|
||||
}
|
||||
|
|
@ -641,7 +654,9 @@ pub fn capture_screen() -> Result<image::RgbImage, String> {
|
|||
return crate::desk::capture();
|
||||
#[allow(unreachable_code)]
|
||||
{
|
||||
let file = std::env::temp_dir().join(format!("anybot-screen-{}-{}.png", std::process::id(), crate::model::now_ms()));
|
||||
// A random tail: two bots screenshotting the same millisecond must not delete each
|
||||
// other's file (pid+time alone collides).
|
||||
let file = std::env::temp_dir().join(format!("anybot-screen-{}-{}-{}.png", std::process::id(), crate::model::now_ms(), crate::httpd::random_hex(4)));
|
||||
let st = std::process::Command::new("screencapture").args(["-x", "-m", "-t", "png"]).arg(&file).status().map_err(|e| e.to_string())?;
|
||||
let bytes = std::fs::read(&file);
|
||||
let _ = std::fs::remove_file(&file);
|
||||
|
|
|
|||
|
|
@ -308,7 +308,11 @@ pub fn screensaver_on() -> bool {
|
|||
if !wayland() {
|
||||
return x11::screensaver_on();
|
||||
}
|
||||
// GNOME speaks org.gnome.ScreenSaver.GetActive at /org/gnome/ScreenSaver; the plain
|
||||
// freedesktop one (KDE & co.) is org.freedesktop.ScreenSaver.IsActive at /ScreenSaver —
|
||||
// the old fallback asked for a path and method that never exist, so on KDE it always
|
||||
// answered "screen is free".
|
||||
dbus_call::<bool>("org.gnome.ScreenSaver", "/org/gnome/ScreenSaver", "org.gnome.ScreenSaver", "GetActive")
|
||||
.or_else(|| dbus_call::<bool>("org.freedesktop.ScreenSaver", "/org/freedesktop/ScreenSaver", "org.freedesktop.ScreenSaver", "GetActive"))
|
||||
.or_else(|| dbus_call::<bool>("org.freedesktop.ScreenSaver", "/ScreenSaver", "org.freedesktop.ScreenSaver", "IsActive"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,11 +43,12 @@ pub fn start() -> Result<u16, String> {
|
|||
Ok(port)
|
||||
}
|
||||
|
||||
/// Who may use the proxy: this machine, and Docker's own networks (172.16.0.0/12, where the
|
||||
/// bridge and compose networks live) — never the rest of the local network.
|
||||
/// Who may use the proxy: this machine, and Docker's default bridge (172.17.0.0/16, where the
|
||||
/// boxes run). The rest of 172.16.0.0/12 is often the corporate LAN — an open proxy there is
|
||||
/// an open door, so it is not served.
|
||||
pub fn allowed_peer(ip: std::net::IpAddr) -> bool {
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v) => v.is_loopback() || (v.octets()[0] == 172 && (16..=31).contains(&v.octets()[1])),
|
||||
std::net::IpAddr::V4(v) => v.is_loopback() || (v.octets()[0] == 172 && v.octets()[1] == 17),
|
||||
std::net::IpAddr::V6(v) => v.is_loopback(),
|
||||
}
|
||||
}
|
||||
|
|
@ -122,15 +123,27 @@ fn handle(mut client: TcpStream) -> Result<(), String> {
|
|||
return Err(e);
|
||||
}
|
||||
};
|
||||
let mut server = match TcpStream::connect((r.host.as_str(), r.port)) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
// Resolve and connect with a real timeout: a plain TcpStream::connect can hang ~2 minutes
|
||||
// on a dead address, and every queued box request piles up behind it.
|
||||
let addrs: Vec<std::net::SocketAddr> = match std::net::ToSocketAddrs::to_socket_addrs(&(r.host.as_str(), r.port)) {
|
||||
Ok(a) => a.collect(),
|
||||
Err(_) => {
|
||||
let _ = client.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n");
|
||||
return Err(e.to_string());
|
||||
return Err(format!("連不到 {}", r.host));
|
||||
}
|
||||
};
|
||||
let mut server = match addrs.into_iter().find_map(|a| TcpStream::connect_timeout(&a, std::time::Duration::from_secs(10)).ok()) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let _ = client.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n");
|
||||
return Err(format!("連不到 {}", r.host));
|
||||
}
|
||||
};
|
||||
CONNECTIONS.fetch_add(1, Ordering::Relaxed);
|
||||
client.set_read_timeout(None).ok();
|
||||
// A dead peer must not pin the copy loops (and their threads) forever: an idle tunnel ends.
|
||||
let idle = std::time::Duration::from_secs(30 * 60);
|
||||
client.set_read_timeout(Some(idle)).ok();
|
||||
server.set_read_timeout(Some(idle)).ok();
|
||||
if r.tunnel {
|
||||
client.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n").map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
|
|
@ -189,8 +202,12 @@ mod tests {
|
|||
#[test]
|
||||
fn only_this_machine_and_containers() {
|
||||
assert!(super::allowed_peer("127.0.0.1".parse().unwrap()));
|
||||
// Docker's default bridge (where the boxes run) is served…
|
||||
assert!(super::allowed_peer("172.17.0.2".parse().unwrap()));
|
||||
assert!(super::allowed_peer("172.31.255.1".parse().unwrap()));
|
||||
// …but the rest of 172.16/12 (often the corporate LAN) is not: an open proxy there
|
||||
// is an open door.
|
||||
assert!(!super::allowed_peer("172.16.0.1".parse().unwrap()));
|
||||
assert!(!super::allowed_peer("172.31.255.1".parse().unwrap()));
|
||||
assert!(!super::allowed_peer("172.32.0.1".parse().unwrap()));
|
||||
assert!(!super::allowed_peer("192.168.1.20".parse().unwrap()));
|
||||
}
|
||||
|
|
|
|||
77
src/httpd.rs
77
src/httpd.rs
|
|
@ -90,24 +90,35 @@ fn reason(status: u16) -> &'static str {
|
|||
}
|
||||
|
||||
fn decode(s: &str) -> String {
|
||||
// Work on bytes only: slicing the &str at byte offsets can land inside a multi-byte
|
||||
// character and panic (an unauthenticated request like "/%)…" used to kill its thread).
|
||||
let b = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let hex = |c: u8| -> Option<u8> {
|
||||
match c {
|
||||
b'0'..=b'9' => Some(c - b'0'),
|
||||
b'a'..=b'f' => Some(c - b'a' + 10),
|
||||
b'A'..=b'F' => Some(c - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
match b[i] {
|
||||
b'+' => out.push(b' '),
|
||||
b'%' if i + 2 < b.len() => {
|
||||
if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
|
||||
out.push(v);
|
||||
i += 2;
|
||||
} else {
|
||||
out.push(b'%');
|
||||
}
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
b'+' => {
|
||||
out.push(b' ');
|
||||
i += 1;
|
||||
}
|
||||
b'%' if i + 2 < b.len() && hex(b[i + 1]).is_some() && hex(b[i + 2]).is_some() => {
|
||||
out.push(hex(b[i + 1]).unwrap() * 16 + hex(b[i + 2]).unwrap());
|
||||
i += 3;
|
||||
}
|
||||
c => {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&out).to_string()
|
||||
}
|
||||
|
||||
|
|
@ -143,11 +154,52 @@ pub fn read_request(c: &mut TcpStream) -> Result<Req, u16> {
|
|||
let method = fp.next().unwrap_or("").to_string();
|
||||
let target = fp.next().unwrap_or("/").to_string();
|
||||
let headers: HashMap<String, String> = lines.filter_map(|l| l.split_once(':')).map(|(k, v)| (k.trim().to_ascii_lowercase(), v.trim().to_string())).collect();
|
||||
let chunked = headers.get("transfer-encoding").map(|v| v.to_ascii_lowercase().contains("chunked")).unwrap_or(false);
|
||||
let mut body = got[head_end + 4..].to_vec();
|
||||
if chunked {
|
||||
// Some phone browsers send POST bodies in chunks (no Content-Length): read chunks
|
||||
// until the zero-size terminator, reassembling the body.
|
||||
let mut out: Vec<u8> = vec![];
|
||||
let mut pos = 0usize;
|
||||
loop {
|
||||
let size_end = loop {
|
||||
if let Some(p) = body[pos..].windows(2).position(|w| w == b"\r\n") {
|
||||
break pos + p;
|
||||
}
|
||||
if body.len() > MAX_BODY + 64 * 1024 {
|
||||
return Err(413);
|
||||
}
|
||||
let n = c.read(&mut buf).map_err(|_| 400u16)?;
|
||||
if n == 0 {
|
||||
return Err(400);
|
||||
}
|
||||
body.extend_from_slice(&buf[..n]);
|
||||
};
|
||||
let hex = String::from_utf8_lossy(&body[pos..size_end]).split(';').next().unwrap_or("0").trim().to_string();
|
||||
let size = usize::from_str_radix(&hex, 16).map_err(|_| 400u16)?;
|
||||
if size == 0 {
|
||||
break;
|
||||
}
|
||||
if out.len() + size > MAX_BODY {
|
||||
return Err(413);
|
||||
}
|
||||
let end = size_end + 2 + size;
|
||||
while body.len() < end + 2 {
|
||||
let n = c.read(&mut buf).map_err(|_| 400u16)?;
|
||||
if n == 0 {
|
||||
return Err(400);
|
||||
}
|
||||
body.extend_from_slice(&buf[..n]);
|
||||
}
|
||||
out.extend_from_slice(&body[size_end + 2..end]);
|
||||
pos = end + 2;
|
||||
}
|
||||
body = out;
|
||||
} else {
|
||||
let len: usize = headers.get("content-length").and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
if len > MAX_BODY {
|
||||
return Err(413);
|
||||
}
|
||||
let mut body = got[head_end + 4..].to_vec();
|
||||
while body.len() < len {
|
||||
let n = c.read(&mut buf).map_err(|_| 400u16)?;
|
||||
if n == 0 {
|
||||
|
|
@ -156,6 +208,7 @@ pub fn read_request(c: &mut TcpStream) -> Result<Req, u16> {
|
|||
body.extend_from_slice(&buf[..n]);
|
||||
}
|
||||
body.truncate(len);
|
||||
}
|
||||
let (path, q) = target.split_once('?').unwrap_or((&target, ""));
|
||||
Ok(Req { method, path: decode(path), query: parse_query(q), headers, body, peer: c.peer_addr().map(|a| a.ip().to_string()).unwrap_or_default() })
|
||||
}
|
||||
|
|
|
|||
16
src/i18n.rs
16
src/i18n.rs
|
|
@ -55,8 +55,22 @@ fn current() -> &'static RwLock<String> {
|
|||
L.get_or_init(|| RwLock::new("zh-TW".into()))
|
||||
}
|
||||
|
||||
/// Replies are wanted in Traditional Chinese (the language setting says so, not "auto").
|
||||
static REPLIES_HANT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// What the bot wrote, in Traditional Chinese with Taiwan wording when that is the setting.
|
||||
/// Models (Qwen especially) slip into Simplified characters despite the instruction.
|
||||
pub fn reply_text(s: &str) -> String {
|
||||
if REPLIES_HANT.load(std::sync::atomic::Ordering::Relaxed) && !s.is_empty() {
|
||||
zhconv::zhconv(s, zhconv::Variant::ZhTW)
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// "auto" follows the system language.
|
||||
pub fn set(tag: &str) {
|
||||
REPLIES_HANT.store(tag == "zh-TW", std::sync::atomic::Ordering::Relaxed);
|
||||
let t = if tag == "auto" || tag.is_empty() { system() } else { tag.to_string() };
|
||||
*current().write().unwrap() = t;
|
||||
}
|
||||
|
|
@ -208,6 +222,8 @@ mod tests {
|
|||
fn languages_and_tables() {
|
||||
assert_eq!(LANGS.len(), 37);
|
||||
assert_eq!(match_tag("zh-Hant-TW"), "zh-TW");
|
||||
set("zh-TW");
|
||||
assert_eq!(reply_text("那边的软件"), "那邊的軟體");
|
||||
assert_eq!(match_tag("zh-Hans-CN"), "zh-CN");
|
||||
assert_eq!(match_tag("ja-JP"), "ja");
|
||||
assert_eq!(match_tag("pt-PT"), "pt-PT");
|
||||
|
|
|
|||
|
|
@ -250,13 +250,19 @@ fn rasterize(icon: Icon, px: u32) -> Option<egui::ColorImage> {
|
|||
pub fn paint(p: &Painter, rect: Rect, icon: Icon, col: Color32) {
|
||||
let ppp = p.ctx().pixels_per_point();
|
||||
let side = rect.width().min(rect.height());
|
||||
let px = (side * ppp).round().max(8.0) as u32;
|
||||
// Quantised size (2 px steps, capped): a resized window used to create a new texture for
|
||||
// every pixel width, so the cache grew without limit while the window was dragged.
|
||||
let px = ((side * ppp).round().max(8.0) as u32 / 2 * 2).clamp(8, 512);
|
||||
let tex = {
|
||||
let mut c = cache().lock().unwrap();
|
||||
match c.get(&(icon, px)) {
|
||||
Some(t) => t.clone(),
|
||||
None => {
|
||||
let Some(img) = rasterize(icon, px) else { return };
|
||||
// Roomy hard cap: past it, start the cache fresh (textures reload on demand).
|
||||
if c.len() >= 1500 {
|
||||
c.clear();
|
||||
}
|
||||
let t = p.ctx().load_texture(format!("icon-{icon:?}-{px}"), img, egui::TextureOptions::LINEAR);
|
||||
c.insert((icon, px), t.clone());
|
||||
t
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ fn jxa(script: &str, args: &Value) -> Result<String, String> {
|
|||
fn permission_hint(app: &str, err: &str) -> String {
|
||||
let e = err.to_lowercase();
|
||||
if e.contains("-1743") || e.contains("not allowed") || e.contains("不允許") || e.contains("not authorized") {
|
||||
let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Automation").spawn();
|
||||
let _ = crate::computer::spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Automation"));
|
||||
return format!("AnyBot 還不能控制這台 Mac 的「{app}」。我已經打開「系統設定 → 隱私權與安全性 → 自動化」:在 AnyBot(或「終端機」)底下打開「{app}」,再叫我試一次。");
|
||||
}
|
||||
format!("「{app}」回報錯誤:{}", err.lines().last().unwrap_or(err))
|
||||
|
|
|
|||
58
src/mcp.rs
58
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<String, String>, session: Option<String> },
|
||||
}
|
||||
|
||||
struct Conn {
|
||||
/// The part a tool call mutates; locked per server, so one slow call (up to two minutes)
|
||||
/// never blocks other servers, the UI's status line, or the app's own sync.
|
||||
struct Live {
|
||||
link: Link,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
struct Conn {
|
||||
live: Mutex<Live>,
|
||||
tools: Vec<ToolInfo>,
|
||||
config: Server,
|
||||
}
|
||||
|
||||
fn conns() -> &'static Mutex<HashMap<String, Conn>> {
|
||||
static C: OnceLock<Mutex<HashMap<String, Conn>>> = OnceLock::new();
|
||||
/// One lock for the table, one lock per server's link.
|
||||
fn conns() -> &'static Mutex<HashMap<String, Arc<Conn>>> {
|
||||
static C: OnceLock<Mutex<HashMap<String, Arc<Conn>>>> = OnceLock::new();
|
||||
C.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
|
|
@ -141,7 +148,7 @@ pub fn parse_http_answer(body: &str, id: u64) -> Result<Value, String> {
|
|||
Err("MCP 伺服器沒有給答案".into())
|
||||
}
|
||||
|
||||
impl Conn {
|
||||
impl Live {
|
||||
fn request(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
|
|
@ -224,13 +231,13 @@ fn connect(s: &Server) -> Result<Conn, String> {
|
|||
});
|
||||
Link::Stdio { child, stdin, rx }
|
||||
};
|
||||
let mut c = Conn { link, next_id: 1, tools: vec![], config: s.clone() };
|
||||
c.request("initialize", json!({"protocolVersion": PROTOCOL, "capabilities": {}, "clientInfo": {"name": "AnyBot", "version": "1"}}))?;
|
||||
c.notify("notifications/initialized");
|
||||
let mut live = Live { link, next_id: 1 };
|
||||
live.request("initialize", json!({"protocolVersion": PROTOCOL, "capabilities": {}, "clientInfo": {"name": "AnyBot", "version": "1"}}))?;
|
||||
live.notify("notifications/initialized");
|
||||
let mut tools = vec![];
|
||||
let mut cursor: Option<String> = None;
|
||||
for _ in 0..20 {
|
||||
let r = c.request("tools/list", match &cursor { Some(x) => json!({"cursor": x}), None => json!({}) })?;
|
||||
let r = live.request("tools/list", match &cursor { Some(x) => json!({"cursor": x}), None => json!({}) })?;
|
||||
for t in r["tools"].as_array().into_iter().flatten() {
|
||||
tools.push(ToolInfo { server: s.name.clone(), name: t["name"].as_str().unwrap_or("").into(), description: t["description"].as_str().unwrap_or("").into(), schema: t["inputSchema"].clone() });
|
||||
}
|
||||
|
|
@ -239,31 +246,37 @@ fn connect(s: &Server) -> Result<Conn, String> {
|
|||
break;
|
||||
}
|
||||
}
|
||||
c.tools = tools;
|
||||
Ok(c)
|
||||
Ok(Conn { live: Mutex::new(live), tools, config: s.clone() })
|
||||
}
|
||||
|
||||
/// Start (or keep) the enabled servers; stop the rest. Returns errors per server.
|
||||
/// Connecting happens outside the table's lock (a slow server must not freeze the UI's
|
||||
/// status line, which reads that lock every second).
|
||||
pub fn sync(servers: &[Server]) -> Vec<(String, String)> {
|
||||
let mut errs = vec![];
|
||||
let mut to_connect: Vec<Server> = vec![];
|
||||
{
|
||||
let mut map = conns().lock().unwrap();
|
||||
let wanted: Vec<&Server> = servers.iter().filter(|s| s.enabled).collect();
|
||||
map.retain(|name, c| {
|
||||
let keep = wanted.iter().any(|s| &s.name == name && **s == c.config);
|
||||
if !keep {
|
||||
if let Link::Stdio { child, .. } = &mut c.link {
|
||||
if let Link::Stdio { child, .. } = &mut c.live.lock().unwrap().link {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
keep
|
||||
});
|
||||
for s in wanted {
|
||||
if map.contains_key(&s.name) {
|
||||
continue;
|
||||
if !map.contains_key(&s.name) {
|
||||
to_connect.push(s.clone());
|
||||
}
|
||||
match connect(s) {
|
||||
}
|
||||
}
|
||||
for s in to_connect {
|
||||
match connect(&s) {
|
||||
Ok(c) => {
|
||||
map.insert(s.name.clone(), c);
|
||||
conns().lock().unwrap().insert(s.name.clone(), Arc::new(c));
|
||||
}
|
||||
Err(e) => errs.push((s.name.clone(), e)),
|
||||
}
|
||||
|
|
@ -305,9 +318,12 @@ pub fn resolve(name: &str) -> Option<(String, String)> {
|
|||
pub fn call(name: &str, args: &str) -> (String, bool, Option<String>) {
|
||||
let Some((server, tool)) = resolve(name) else { return (format!("沒有「{name}」這個 MCP 工具(伺服器可能已關閉)"), false, None) };
|
||||
let arguments: Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(json!({}));
|
||||
let mut map = conns().lock().unwrap();
|
||||
let Some(c) = map.get_mut(&server) else { return ("MCP 伺服器沒有連線".into(), false, None) };
|
||||
match c.request("tools/call", json!({"name": tool, "arguments": arguments})) {
|
||||
// Copy the server's own handle out of the table, then lock its link alone: a two-minute
|
||||
// call to one server used to block every other MCP tool and the UI's status line.
|
||||
let conn = conns().lock().unwrap().get(&server).cloned();
|
||||
let Some(conn) = conn else { return ("MCP 伺服器沒有連線".into(), false, None) };
|
||||
let mut live = conn.live.lock().unwrap();
|
||||
match live.request("tools/call", json!({"name": tool, "arguments": arguments})) {
|
||||
Ok(r) => {
|
||||
let mut text = String::new();
|
||||
let mut image = None;
|
||||
|
|
@ -336,8 +352,8 @@ pub fn call(name: &str, args: &str) -> (String, bool, Option<String>) {
|
|||
}
|
||||
|
||||
pub fn shutdown() {
|
||||
for (_, c) in conns().lock().unwrap().iter_mut() {
|
||||
if let Link::Stdio { child, .. } = &mut c.link {
|
||||
for c in conns().lock().unwrap().values() {
|
||||
if let Link::Stdio { child, .. } = &mut c.live.lock().unwrap().link {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
35
src/mic.rs
35
src/mic.rs
|
|
@ -60,6 +60,18 @@ pub fn db(samples: &[f32]) -> f64 {
|
|||
if rms <= 1e-8 { -160.0 } else { 20.0 * rms.log10() }
|
||||
}
|
||||
|
||||
/// Append the mono mix of `data` to the buffer. Runs inside cpal's real-time callback, so it
|
||||
/// only takes the lock with `try_lock` (blocking there starves the audio device → xruns and
|
||||
/// crackly audio; the reader sleeps 100 ms between looks, so a skipped block is rare and
|
||||
/// harmless) and does no per-sample allocation.
|
||||
fn mix<T: Copy>(data: &[T], channels: usize, buf: &Arc<Mutex<Vec<f32>>>, to_f32: impl Fn(T) -> f32) {
|
||||
let Ok(mut g) = buf.try_lock() else { return };
|
||||
g.reserve(data.len() / channels.max(1));
|
||||
for frame in data.chunks(channels) {
|
||||
g.push(frame.iter().map(|s| to_f32(*s)).sum::<f32>() / frame.len() as f32);
|
||||
}
|
||||
}
|
||||
|
||||
fn run(file: PathBuf, listen: Option<Listen>, max_secs: u64, stop: Arc<AtomicBool>) -> &'static str {
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
let host = cpal::default_host();
|
||||
|
|
@ -69,27 +81,12 @@ fn run(file: PathBuf, listen: Option<Listen>, max_secs: u64, stop: Arc<AtomicBoo
|
|||
let channels = cfg.channels().max(1) as usize;
|
||||
let buf: Arc<Mutex<Vec<f32>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let b = buf.clone();
|
||||
let mono = move |data: &[f32]| {
|
||||
let mut g = b.lock().unwrap();
|
||||
for frame in data.chunks(channels) {
|
||||
g.push(frame.iter().sum::<f32>() / frame.len() as f32);
|
||||
}
|
||||
};
|
||||
let err = |_| {};
|
||||
let stream = match cfg.sample_format() {
|
||||
cpal::SampleFormat::F32 => device.build_input_stream(cfg.config(), move |d: &[f32], _: &_| mono(d), err, None),
|
||||
cpal::SampleFormat::I16 => {
|
||||
let m = mono;
|
||||
device.build_input_stream(cfg.config(), move |d: &[i16], _: &_| m(&d.iter().map(|s| *s as f32 / 32768.0).collect::<Vec<_>>()), err, None)
|
||||
}
|
||||
cpal::SampleFormat::I32 => {
|
||||
let m = mono;
|
||||
device.build_input_stream(cfg.config(), move |d: &[i32], _: &_| m(&d.iter().map(|s| *s as f32 / 2_147_483_648.0).collect::<Vec<_>>()), err, None)
|
||||
}
|
||||
cpal::SampleFormat::U8 => {
|
||||
let m = mono;
|
||||
device.build_input_stream(cfg.config(), move |d: &[u8], _: &_| m(&d.iter().map(|s| (*s as f32 - 128.0) / 128.0).collect::<Vec<_>>()), err, None)
|
||||
}
|
||||
cpal::SampleFormat::F32 => device.build_input_stream(cfg.config(), move |d: &[f32], _: &_| mix(d, channels, &b, |x| x), err, None),
|
||||
cpal::SampleFormat::I16 => device.build_input_stream(cfg.config(), move |d: &[i16], _: &_| mix(d, channels, &b, |x| x as f32 / 32768.0), err, None),
|
||||
cpal::SampleFormat::I32 => device.build_input_stream(cfg.config(), move |d: &[i32], _: &_| mix(d, channels, &b, |x| x as f32 / 2_147_483_648.0), err, None),
|
||||
cpal::SampleFormat::U8 => device.build_input_stream(cfg.config(), move |d: &[u8], _: &_| mix(d, channels, &b, |x| (x as f32 - 128.0) / 128.0), err, None),
|
||||
_ => return "fail",
|
||||
};
|
||||
let Ok(stream) = stream else { return "fail" };
|
||||
|
|
|
|||
45
src/model.rs
45
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::<State>(&s).ok())
|
||||
.unwrap_or_else(State::first_run);
|
||||
let p = Self::path();
|
||||
let raw = std::fs::read_to_string(&p);
|
||||
let parsed = raw.as_ref().ok().and_then(|s| serde_json::from_str::<State>(s).ok());
|
||||
let mut s = match parsed {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
// A damaged save must not vanish quietly: keep the bytes (so the person can
|
||||
// recover them), then start fresh. Silently wiping left no trace before.
|
||||
if let Ok(raw) = raw.as_ref() {
|
||||
let damaged = p.with_extension(format!("json.damaged-{}", chrono::Utc::now().timestamp()));
|
||||
let _ = std::fs::write(&damaged, raw);
|
||||
}
|
||||
State::first_run()
|
||||
}
|
||||
};
|
||||
s.drop_untouched_samples();
|
||||
for b in s.bots.iter_mut() {
|
||||
b.repair_interrupted();
|
||||
|
|
@ -424,11 +437,16 @@ impl State {
|
|||
}
|
||||
if let Ok(s) = serde_json::to_string_pretty(self) {
|
||||
let tmp = p.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp, s).is_ok() {
|
||||
// Flush the bytes before renaming, so a power cut cannot leave a renamed-but-empty
|
||||
// file where load() would find it (and treat it as a first run).
|
||||
if let Ok(mut f) = std::fs::File::create(&tmp) {
|
||||
use std::io::Write;
|
||||
if f.write_all(s.as_bytes()).and_then(|_| f.sync_all()).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, &p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn provider(&self, kind: ProviderKind) -> ProviderSettings {
|
||||
self.providers.get(kind.key()).cloned().unwrap_or_default()
|
||||
|
|
@ -481,12 +499,15 @@ impl State {
|
|||
pub fn take_in_bots(&mut self, other: &State) -> usize {
|
||||
let last = |b: &Bot| b.messages.last().map(|m| (m.at, m.text.clone()));
|
||||
let mut n = 0;
|
||||
let mut reid: Vec<(u64, u64)> = vec![];
|
||||
for b in &other.bots {
|
||||
if self.bots.iter().any(|o| o.name == b.name && (b.messages.is_empty() || (o.messages.len() == b.messages.len() && last(o) == last(b)))) {
|
||||
continue;
|
||||
}
|
||||
let mut b = b.clone();
|
||||
let old_id = b.id;
|
||||
b.id = self.next_id.max(1);
|
||||
reid.push((old_id, b.id));
|
||||
self.next_id = b.id + 1;
|
||||
for r in b.routines.iter_mut() {
|
||||
r.id = self.next_routine_id.max(1);
|
||||
|
|
@ -495,6 +516,20 @@ impl State {
|
|||
self.bots.push(b);
|
||||
n += 1;
|
||||
}
|
||||
// Group members are bot ids from the other machine: follow them to the copies that
|
||||
// just landed, or a group answers with bots that have nothing to do with it.
|
||||
if !reid.is_empty() {
|
||||
for g in self.bots.iter_mut() {
|
||||
if g.members.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for m in g.members.iter_mut() {
|
||||
if let Some((_, new)) = reid.iter().find(|(o, _)| *o == *m) {
|
||||
*m = *new;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keys, skills and connectors this computer doesn't have yet.
|
||||
for (k, v) in &other.providers {
|
||||
let mine = self.providers.entry(k.clone()).or_default();
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -87,8 +87,10 @@ pub fn describe(name: &str, args: &Value) -> String {
|
|||
}
|
||||
|
||||
fn find<'a>(pages: &'a [Page], bot: u64, id: Option<u64>, title: &str) -> Option<&'a Page> {
|
||||
// A page belongs to the bot that made it: ids are guesses a model can make, and reading
|
||||
// (or overwriting) another bot's page by id must not work.
|
||||
match id {
|
||||
Some(i) => pages.iter().find(|p| p.id == i),
|
||||
Some(i) => pages.iter().find(|p| p.id == i && p.bot == bot),
|
||||
None => pages.iter().find(|p| p.bot == bot && same_title(&p.title, title)),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ const PAIRS: &[(&str, &str)] = &[
|
|||
|
||||
/// Mac wording → this system's wording (unchanged on macOS).
|
||||
pub fn localize(s: &str) -> String {
|
||||
if cfg!(target_os = "macos") || !s.contains("Mac") && !s.contains("Finder") {
|
||||
// "macOS Keychain" and "macOS 鑰匙圈" contain no "Mac" (lowercase m), so the quick
|
||||
// guard has to test "macOS" as well — those pairs used to never apply.
|
||||
if cfg!(target_os = "macos") || !s.contains("Mac") && !s.contains("macOS") && !s.contains("Finder") {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out = s.to_string();
|
||||
|
|
@ -75,9 +77,14 @@ mod tests {
|
|||
|
||||
fn dialog(zenity: &[&str], kdialog: &[&str]) -> Option<String> {
|
||||
for (cmd, args) in [("zenity", zenity), ("kdialog", kdialog)] {
|
||||
if let Ok(o) = std::process::Command::new(cmd).args(args).stderr(std::process::Stdio::null()).output() {
|
||||
match std::process::Command::new(cmd).args(args).stderr(std::process::Stdio::null()).output() {
|
||||
// Exit code 1 = the person pressed Cancel: don't try the other one.
|
||||
return o.status.success().then(|| String::from_utf8_lossy(&o.stdout).trim_end_matches('\n').to_string());
|
||||
Ok(o) if o.status.success() => return Some(String::from_utf8_lossy(&o.stdout).trim_end_matches('\n').to_string()),
|
||||
Ok(o) if o.status.code() == Some(1) => return None,
|
||||
// Missing binary (Err) or a tool error (zenity 5, kdialog 2): fall through to
|
||||
// the other tool — the old code returned None on any spawn, so a broken zenity
|
||||
// hid the working kdialog.
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
None
|
||||
|
|
|
|||
|
|
@ -37,10 +37,16 @@ pub struct Screen {
|
|||
struct Live {
|
||||
tx: Sender<Cmd>,
|
||||
screens: Vec<Screen>,
|
||||
/// Which started session this is, so its own thread (and only it) can clear LIVE when it ends.
|
||||
id: u64,
|
||||
}
|
||||
|
||||
static LIVE: Mutex<Option<Live>> = Mutex::new(None);
|
||||
static LAST_ERROR: Mutex<String> = Mutex::new(String::new());
|
||||
/// One start at a time: two concurrent ensure() calls used to open two portal sessions, and
|
||||
/// the first to end then cleared the second's LIVE entry (the window went blind).
|
||||
static ENSURE: Mutex<()> = Mutex::new(());
|
||||
static NEXT_SESSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
fn token_file() -> std::path::PathBuf {
|
||||
crate::model::State::path().parent().map(|d| d.join("wayland-remote-token.txt")).unwrap_or_default()
|
||||
|
|
@ -63,9 +69,11 @@ pub fn last_error() -> String {
|
|||
|
||||
/// Start the session if it isn't running (the first time, the desktop asks the person).
|
||||
fn ensure() -> Option<Vec<Screen>> {
|
||||
let _start = ENSURE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(l) = LIVE.lock().unwrap().as_ref() {
|
||||
return Some(l.screens.clone());
|
||||
}
|
||||
let id = NEXT_SESSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let (ready_tx, ready_rx) = channel::<Result<Vec<Screen>, String>>();
|
||||
let (tx, rx) = channel::<Cmd>();
|
||||
std::thread::spawn(move || {
|
||||
|
|
@ -147,12 +155,17 @@ fn ensure() -> Option<Vec<Screen>> {
|
|||
}
|
||||
}
|
||||
let _ = session.close().await;
|
||||
*LIVE.lock().unwrap() = None;
|
||||
// Only the session that is still the current one retires the slot (an older
|
||||
// session ending must not wipe the newer one that replaced it).
|
||||
let mut live = LIVE.lock().unwrap();
|
||||
if live.as_ref().map(|l| l.id) == Some(id) {
|
||||
*live = None;
|
||||
}
|
||||
});
|
||||
});
|
||||
match ready_rx.recv_timeout(Duration::from_secs(120)) {
|
||||
Ok(Ok(screens)) => {
|
||||
*LIVE.lock().unwrap() = Some(Live { tx, screens: screens.clone() });
|
||||
*LIVE.lock().unwrap() = Some(Live { tx, screens: screens.clone(), id });
|
||||
Some(screens)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
|
|
|
|||
|
|
@ -114,7 +114,8 @@ pub fn system_open(target: impl AsRef<std::ffi::OsStr>) -> std::process::Command
|
|||
}
|
||||
|
||||
pub fn open_with_system(target: &str) {
|
||||
if let Err(e) = system_open(target).spawn() {
|
||||
let mut c = system_open(target);
|
||||
if let Err(e) = crate::computer::spawn_reaped(&mut c) {
|
||||
eprintln!("anybot: couldn't open {target}: {e}");
|
||||
}
|
||||
}
|
||||
|
|
@ -138,7 +139,13 @@ fn clip(t: String) -> String {
|
|||
}
|
||||
|
||||
fn read_text(path: &Path) -> Result<String, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("讀不到檔案:{e}"))?;
|
||||
use std::io::Read;
|
||||
// Read only the head of the file: a preview never shows more than clip() keeps (200 K
|
||||
// chars), and reading a multi-GB log whole on the UI thread froze the whole window.
|
||||
const HEAD: u64 = 4 << 20;
|
||||
let mut f = std::fs::File::open(path).map_err(|e| format!("讀不到檔案:{e}"))?;
|
||||
let mut bytes = Vec::new();
|
||||
f.by_ref().take(HEAD).read_to_end(&mut bytes).map_err(|e| format!("讀不到檔案:{e}"))?;
|
||||
if bytes.iter().take(8000).any(|b| *b == 0) {
|
||||
return Err("這是二進位檔案,沒辦法當文字顯示。".into());
|
||||
}
|
||||
|
|
@ -456,15 +463,36 @@ fn md_table(rows: &[Vec<String>]) -> String {
|
|||
s
|
||||
}
|
||||
|
||||
/// Find `<tag` followed by `>` or a space, ASCII case-insensitive, on the original string.
|
||||
/// (Lowercasing the whole page first shifts byte offsets — "İ" becomes 3 bytes when lowered —
|
||||
/// and cutting the original at those offsets panics.)
|
||||
fn find_open_tag(s: &str, tag: &str) -> Option<usize> {
|
||||
let (b, t) = (s.as_bytes(), tag.as_bytes());
|
||||
(0..b.len()).find(|&i| {
|
||||
b[i] == b'<'
|
||||
&& b.len() >= i + 1 + t.len()
|
||||
&& b[i + 1..].iter().zip(t).all(|(a, c)| a.eq_ignore_ascii_case(c))
|
||||
&& matches!(b.get(i + 1 + t.len()), Some(c) if *c == b'>' || c.is_ascii_whitespace())
|
||||
})
|
||||
}
|
||||
|
||||
/// `needle` (ASCII) in `hay`, ASCII case-insensitive, as a byte offset.
|
||||
fn find_ci(hay: &str, needle: &str) -> Option<usize> {
|
||||
let (h, n) = (hay.as_bytes(), needle.as_bytes());
|
||||
if n.is_empty() || h.len() < n.len() {
|
||||
return None;
|
||||
}
|
||||
(0..=h.len() - n.len()).find(|&i| h[i..i + n.len()].iter().zip(n).all(|(a, c)| a.eq_ignore_ascii_case(c)))
|
||||
}
|
||||
|
||||
/// A web page as Markdown: headings, paragraphs, lists, tables, bold, links ("text (url)").
|
||||
/// Scripts, styles and the <head> are dropped.
|
||||
pub fn html_markdown(html: &str) -> String {
|
||||
let mut s = html.to_string();
|
||||
for tag in ["head", "script", "style", "noscript", "svg", "template"] {
|
||||
loop {
|
||||
let low = s.to_lowercase();
|
||||
let Some(a) = low.find(&format!("<{tag}")).filter(|a| low[a + tag.len() + 1..].starts_with(|c: char| c == '>' || c.is_whitespace())) else { break };
|
||||
let end = low[a..].find(&format!("</{tag}>")).map(|e| a + e + tag.len() + 3).unwrap_or(s.len());
|
||||
let Some(a) = find_open_tag(&s, tag) else { break };
|
||||
let end = find_ci(&s[a..], &format!("</{tag}>")).map(|e| a + e + tag.len() + 3).unwrap_or(s.len());
|
||||
s.replace_range(a..end, "");
|
||||
}
|
||||
}
|
||||
|
|
@ -586,7 +614,12 @@ pub fn html_markdown(html: &str) -> String {
|
|||
}
|
||||
|
||||
fn load_sheet(path: &Path, name: &str) -> Result<Preview, String> {
|
||||
// calamine parses the whole workbook on the UI thread: a huge .xlsx froze the window.
|
||||
// (CSV/TSV are fine: read_text reads only the head.)
|
||||
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||
if matches!(ext.as_str(), "xlsx" | "xls" | "ods") && std::fs::metadata(path).map(|m| m.len() > 50 << 20).unwrap_or(false) {
|
||||
return Err("這個試算表太大(超過 50 MB),在 AnyBot 裡預覽會卡住;請用其他 App 打開。".into());
|
||||
}
|
||||
if ext == "csv" || ext == "tsv" {
|
||||
let text = read_text(path)?;
|
||||
let sep = if ext == "tsv" { '\t' } else { ',' };
|
||||
|
|
@ -623,6 +656,11 @@ fn load_sheet(path: &Path, name: &str) -> Result<Preview, String> {
|
|||
|
||||
/// An Excel date number (days since 1899-12-30) as "2026-10-31" or "2026-10-29 20:13".
|
||||
pub fn excel_date(v: f64) -> String {
|
||||
// A cell formatted as a date can hold any number (Excel cells go to 1e308); beyond any
|
||||
// real date, show the number — the date maths would overflow chrono and panic.
|
||||
if !v.is_finite() || v.abs() > 3_000_000.0 {
|
||||
return if v.fract().abs() < 0.000001 { format!("{}", v as i64) } else { format!("{v}") };
|
||||
}
|
||||
let base = chrono::NaiveDate::from_ymd_opt(1899, 12, 30).unwrap().and_hms_opt(0, 0, 0).unwrap();
|
||||
let t = base + chrono::Duration::milliseconds((v * 86_400_000.0).round() as i64);
|
||||
if v.fract().abs() < 1e-9 {
|
||||
|
|
|
|||
|
|
@ -188,7 +188,9 @@ pub enum StreamEvent {
|
|||
/// The next model step starts: add a fresh reply bubble.
|
||||
NextStep(String),
|
||||
/// The oldest `n` messages were folded into this summary.
|
||||
Compacted(usize, Message),
|
||||
/// Older messages were folded into this summary; `cut` is the count in the model's view and
|
||||
/// `boundary` the timestamp of the first kept message (the window's own copy may be longer).
|
||||
Compacted(usize, Message, i64),
|
||||
/// A message the person sent mid-turn was handed to the model (add it to the chat).
|
||||
Steered(Message),
|
||||
/// Do something in the app (routines, bots, settings) and answer on the sender.
|
||||
|
|
@ -275,6 +277,10 @@ fn agent() -> ureq::Agent {
|
|||
.http_status_as_error(false)
|
||||
.timeout_connect(Some(Duration::from_millis(CONNECT_TIMEOUT_MS)))
|
||||
.timeout_recv_response(Some(Duration::from_secs(FIRST_RESPONSE_TIMEOUT_SECS)))
|
||||
// A stream that goes silent (a NAT that dropped the mapping, a server that died without
|
||||
// a FIN) must end: without a read timeout the lines() loop blocks forever, and every
|
||||
// such turn leaks a thread and its socket. 5 minutes of total silence is a dead stream.
|
||||
.timeout_per_read(Some(Duration::from_secs(300)))
|
||||
.build();
|
||||
config.into()
|
||||
}
|
||||
|
|
@ -414,8 +420,12 @@ pub fn run_step(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEven
|
|||
}
|
||||
}
|
||||
|
||||
fn read_sse(body: ureq::Body, cancel: &AtomicBool, send: &dyn Fn(StreamEvent), mut on_data: impl FnMut(&str, &str) -> bool) -> Result<(), Failure> {
|
||||
let reader = BufReader::new(body.into_reader());
|
||||
fn read_sse(body: ureq::Body, cancel: &AtomicBool, send: &dyn Fn(StreamEvent), on_data: impl FnMut(&str, &str) -> bool) -> Result<(), Failure> {
|
||||
read_sse_from(body.into_reader(), cancel, send, on_data)
|
||||
}
|
||||
|
||||
fn read_sse_from(r: impl std::io::Read, cancel: &AtomicBool, send: &dyn Fn(StreamEvent), mut on_data: impl FnMut(&str, &str) -> bool) -> Result<(), Failure> {
|
||||
let reader = BufReader::new(r);
|
||||
let mut event = String::new();
|
||||
for line in reader.lines() {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
|
|
@ -545,6 +555,19 @@ pub fn set_eyes(v: Vec<Eyes>) {
|
|||
*EYES.lock().unwrap() = v;
|
||||
}
|
||||
|
||||
/// The helper list, the description cache and the failure list are process-wide: tests that
|
||||
/// touch them must run one at a time, from a clean slate, or they undo each other (the
|
||||
/// describe cache hit in one test made another's helper never get called).
|
||||
#[cfg(test)]
|
||||
pub fn vision_test_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||
static L: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
let g = L.lock().unwrap_or_else(|e| e.into_inner());
|
||||
notes().lock().unwrap().clear();
|
||||
failed_eyes().lock().unwrap().clear();
|
||||
set_eyes(vec![]);
|
||||
g
|
||||
}
|
||||
|
||||
/// Models likely to see pictures, by provider.
|
||||
pub fn seeing_models(kind: ProviderKind) -> &'static [&'static str] {
|
||||
match kind {
|
||||
|
|
@ -750,15 +773,34 @@ fn stream_openai_compatible(req: &ChatRequest, cancel: &AtomicBool, send: &dyn F
|
|||
body["tools"] = json!(req.tools.iter().map(|t| json!({"type": "function", "function": t})).collect::<Vec<_>>());
|
||||
}
|
||||
let url = format!("{}/chat/completions", req.base_url);
|
||||
let mut r = agent().post(&url).header("Content-Type", "application/json");
|
||||
let mut headers: Vec<(String, String)> = vec![];
|
||||
if !req.api_key.is_empty() {
|
||||
r = r.header("Authorization", &format!("Bearer {}", req.api_key));
|
||||
headers.push(("Authorization".into(), format!("Bearer {}", req.api_key)));
|
||||
}
|
||||
if req.kind == ProviderKind::OpenRouter {
|
||||
r = r.header("X-Title", "AnyBot");
|
||||
headers.push(("X-Title".into(), "AnyBot".into()));
|
||||
}
|
||||
if req.kind == ProviderKind::OpenCodeGo {
|
||||
r = r.header("x-opencode-session", &opencode_session(req));
|
||||
headers.push(("x-opencode-session".into(), opencode_session(req)));
|
||||
}
|
||||
// A brain on a server, a model on the person's own network: through their computer (the
|
||||
// whole answer comes back at once, so no word-by-word streaming).
|
||||
let via_hands: Option<Vec<u8>> = match crate::remote::model_hands_for(&url) {
|
||||
Some(hand) => {
|
||||
let (status, bytes) = crate::remote::model_via_hands(&hand, &url, &headers, &body.to_string()).map_err(Failure::retry)?;
|
||||
if status >= 400 {
|
||||
return Err(http_error(status, &String::from_utf8_lossy(&bytes), None));
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let resp = match via_hands {
|
||||
Some(_) => None,
|
||||
None => {
|
||||
let mut r = agent().post(&url).header("Content-Type", "application/json");
|
||||
for (k, v) in &headers {
|
||||
r = r.header(k, v);
|
||||
}
|
||||
// SAND-E0418 turnBackendUnreachable: retryable.
|
||||
let resp = r.send_json(&body).map_err(|e| Failure::retry(connect_error(req.kind, &e.to_string())))?;
|
||||
|
|
@ -768,11 +810,18 @@ fn stream_openai_compatible(req: &ChatRequest, cancel: &AtomicBool, send: &dyn F
|
|||
let text = resp.into_body().read_to_string().unwrap_or_default();
|
||||
return Err(http_error(status, &text, ra));
|
||||
}
|
||||
Some(resp)
|
||||
}
|
||||
};
|
||||
let mut out = StepOutput::default();
|
||||
// Tool calls arrive in pieces keyed by index.
|
||||
let mut pending: Vec<(String, String, String)> = vec![];
|
||||
let mut error = None;
|
||||
read_sse(resp.into_body(), cancel, send, |_ev, data| {
|
||||
let reader: Box<dyn std::io::Read> = match (resp, via_hands) {
|
||||
(Some(r), _) => Box::new(r.into_body().into_reader()),
|
||||
(None, b) => Box::new(std::io::Cursor::new(b.unwrap_or_default())),
|
||||
};
|
||||
read_sse_from(reader, cancel, send, |_ev, data| {
|
||||
if data == "[DONE]" {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -798,7 +847,20 @@ fn stream_openai_compatible(req: &ChatRequest, cancel: &AtomicBool, send: &dyn F
|
|||
}
|
||||
if let Some(calls) = delta.get("tool_calls").and_then(Value::as_array) {
|
||||
for c in calls {
|
||||
let i = c["index"].as_u64().unwrap_or(pending.len() as u64) as usize;
|
||||
let i = match c["index"].as_u64() {
|
||||
Some(i) => i as usize,
|
||||
// Some OpenAI-compatible proxies drop the index: a part carrying an
|
||||
// id or a name starts a new call; an arguments-only part continues the
|
||||
// last one (starting a fresh slot used to lose the call's tail).
|
||||
None => {
|
||||
let starts = c["id"].is_string() || c["function"]["name"].is_string();
|
||||
if starts || pending.is_empty() {
|
||||
pending.len()
|
||||
} else {
|
||||
pending.len() - 1
|
||||
}
|
||||
}
|
||||
};
|
||||
while pending.len() <= i {
|
||||
pending.push(Default::default());
|
||||
}
|
||||
|
|
@ -1335,6 +1397,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn a_model_that_cannot_see_gets_no_pictures() {
|
||||
let _g = vision_test_guard();
|
||||
assert!(image_refused("錯誤 400:At most 0 image(s) may be provided in one prompt. (parameter=image)"));
|
||||
assert!(image_refused("This model does not support image input"));
|
||||
assert!(image_refused("unknown variant `image_url`, expected `text`"));
|
||||
|
|
@ -1425,6 +1488,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn a_model_that_sees_describes_pictures_for_one_that_cannot() {
|
||||
let _g = vision_test_guard();
|
||||
let (blind_url, blind_rx) = fake_model(true, "好的,我看到描述了");
|
||||
let (eyes_url, eyes_rx) = fake_model(false, "一張報表截圖,上面寫著「十月營收 42 萬」。");
|
||||
set_eyes(vec![
|
||||
|
|
|
|||
212
src/remote.rs
212
src/remote.rs
|
|
@ -142,7 +142,8 @@ pub struct Hub {
|
|||
cmds: Mutex<Vec<Cmd>>,
|
||||
repaint: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
|
||||
settings: Mutex<Settings>,
|
||||
fails: Mutex<(u32, Option<Instant>)>,
|
||||
/// Wrong logins, counted per source (one noisy LAN device must not lock out the owner).
|
||||
fails: Mutex<HashMap<String, (u32, Instant)>>,
|
||||
relay_status: Mutex<String>,
|
||||
sync: Mutex<Vec<SyncReq>>,
|
||||
}
|
||||
|
|
@ -162,7 +163,7 @@ pub struct SyncReq {
|
|||
|
||||
pub fn hub() -> &'static Hub {
|
||||
static H: OnceLock<Hub> = OnceLock::new();
|
||||
H.get_or_init(|| Hub { view: Mutex::new(View::default()), changed: Condvar::new(), cmds: Mutex::new(vec![]), repaint: Mutex::new(None), settings: Mutex::new(Settings::default()), fails: Mutex::new((0, None)), relay_status: Mutex::new(String::new()), sync: Mutex::new(vec![]) })
|
||||
H.get_or_init(|| Hub { view: Mutex::new(View::default()), changed: Condvar::new(), cmds: Mutex::new(vec![]), repaint: Mutex::new(None), settings: Mutex::new(Settings::default()), fails: Mutex::new(HashMap::new()), relay_status: Mutex::new(String::new()), sync: Mutex::new(vec![]) })
|
||||
}
|
||||
|
||||
static VERSION: AtomicU64 = AtomicU64::new(1);
|
||||
|
|
@ -487,28 +488,35 @@ pub fn role_allows(role: &str, method: &str, path: &[&str]) -> bool {
|
|||
}
|
||||
|
||||
fn login(req: &Req) -> Resp {
|
||||
// One counter per source address, cleaned as it is touched.
|
||||
let ip = req.peer.split(':').next().unwrap_or(&req.peer).to_string();
|
||||
{
|
||||
let mut f = hub().fails.lock().unwrap();
|
||||
if let (n, Some(t)) = *f {
|
||||
if n >= MAX_FAILS && t.elapsed() < Duration::from_secs(60) {
|
||||
f.retain(|_, (_, t)| t.elapsed() < Duration::from_secs(60));
|
||||
if let Some((n, t)) = f.get(&ip) {
|
||||
if *n >= MAX_FAILS && t.elapsed() < Duration::from_secs(60) {
|
||||
return Resp::error(429, "密碼錯太多次,請一分鐘後再試");
|
||||
}
|
||||
if t.elapsed() >= Duration::from_secs(60) {
|
||||
*f = (0, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
let b = req.json();
|
||||
let pw = b["password"].as_str().unwrap_or("");
|
||||
let stored = hub().settings().password;
|
||||
if stored.is_empty() || !httpd::check_password(pw, &stored) {
|
||||
{
|
||||
let mut f = hub().fails.lock().unwrap();
|
||||
*f = (f.0 + 1, Some(Instant::now()));
|
||||
let e = f.entry(ip.clone()).or_insert((0, Instant::now()));
|
||||
if e.1.elapsed() >= Duration::from_secs(60) {
|
||||
*e = (0, Instant::now());
|
||||
}
|
||||
e.0 += 1;
|
||||
e.1 = Instant::now();
|
||||
}
|
||||
// Slow guessing down a little more.
|
||||
std::thread::sleep(Duration::from_millis(600));
|
||||
return Resp::error(401, if stored.is_empty() { "這台 AnyBot 還沒設定手機密碼" } else { "密碼不對" });
|
||||
}
|
||||
*hub().fails.lock().unwrap() = (0, None);
|
||||
hub().fails.lock().unwrap().remove(&ip);
|
||||
let token = httpd::random_hex(32);
|
||||
let name = b["device"].as_str().filter(|d| !d.trim().is_empty()).unwrap_or("手機").chars().take(40).collect::<String>();
|
||||
let d = Device { name, token_hash: httpd::sha256_hex(&token), created: chrono::Utc::now().timestamp(), last_seen: chrono::Utc::now().timestamp() };
|
||||
|
|
@ -619,6 +627,10 @@ fn device_run(v: &Value, s: &Settings, via_relay: bool, from: &str) -> Resp {
|
|||
let tool = v["tool"].as_str().unwrap_or("");
|
||||
let args = v["args"].as_str().unwrap_or("{}");
|
||||
let who = v["who"].as_str().unwrap_or("遠端 AnyBot");
|
||||
// The brain's model request to a model only this computer's network reaches.
|
||||
if tool == MODEL_FETCH {
|
||||
return model_fetch_here(args);
|
||||
}
|
||||
if crate::computer::is_computer_tool(tool) {
|
||||
let (text, ok, img) = crate::computer::run_by(tool, args, who);
|
||||
return Resp::json(&json!({"text": text, "ok": ok, "image": img}));
|
||||
|
|
@ -645,6 +657,98 @@ fn device_run(v: &Value, s: &Settings, via_relay: bool, from: &str) -> Resp {
|
|||
Resp::error(400, "這個工具不能遠端使用")
|
||||
}
|
||||
|
||||
// ---------- a model on the person's network, for a brain on a server ----------
|
||||
// A brain in the cloud can't reach a model at a private address (a Qwen server at work, Ollama at
|
||||
// home). Its hands can: the brain hands them the request and gets the whole answer back.
|
||||
|
||||
const MODEL_FETCH: &str = "model_fetch";
|
||||
|
||||
/// Private, link-local or loopback address (by name: localhost and *.local).
|
||||
fn private_host(host: &str) -> bool {
|
||||
let h = host.trim_matches(['[', ']']);
|
||||
if h == "localhost" || h.ends_with(".local") {
|
||||
return true;
|
||||
}
|
||||
match h.parse::<std::net::IpAddr>() {
|
||||
// Not link-local: a cloud VM's metadata service (169.254.169.254) must never be
|
||||
// reachable through the hands, only real model servers on the person's network.
|
||||
Ok(std::net::IpAddr::V4(a)) => (a.is_private() || a.is_loopback()) && !a.is_link_local(),
|
||||
Ok(std::net::IpAddr::V6(a)) => (a.is_loopback() || (a.segments()[0] & 0xfe00) == 0xfc00) && !a.is_unicast_link_local(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn host_port(url: &str) -> Option<(String, u16)> {
|
||||
let (scheme, rest) = url.split_once("://")?;
|
||||
let auth = rest.split('/').next()?;
|
||||
let auth = auth.rsplit('@').next()?;
|
||||
let (h, p) = match auth.rsplit_once(':') {
|
||||
Some((h, p)) if !h.ends_with(']') || auth.starts_with('[') => (h.to_string(), p.parse().ok()?),
|
||||
_ => (auth.to_string(), if scheme == "https" { 443 } else { 80 }),
|
||||
};
|
||||
Some((h, p))
|
||||
}
|
||||
|
||||
/// The hands to send a model request through: the model is at a private address this brain
|
||||
/// can't open a connection to, and the person's computer lends this brain its hands.
|
||||
pub fn model_hands_for(url: &str) -> Option<String> {
|
||||
let s = hub().settings();
|
||||
let hand = my_hands(&s.host_id).into_iter().next()?;
|
||||
let (h, p) = host_port(url)?;
|
||||
if !private_host(&h) || h == "localhost" || h.starts_with("127.") {
|
||||
return None;
|
||||
}
|
||||
static SEEN: Mutex<Option<HashMap<String, (bool, std::time::Instant)>>> = Mutex::new(None);
|
||||
let key = format!("{h}:{p}");
|
||||
if let Some((reach, at)) = SEEN.lock().unwrap().get_or_insert_with(HashMap::new).get(&key) {
|
||||
if at.elapsed().as_secs() < 300 {
|
||||
return if *reach { None } else { Some(hand) };
|
||||
}
|
||||
}
|
||||
use std::net::ToSocketAddrs;
|
||||
let reach = (h.as_str(), p).to_socket_addrs().ok().and_then(|mut a| a.next()).map(|a| std::net::TcpStream::connect_timeout(&a, std::time::Duration::from_millis(1500)).is_ok()).unwrap_or(false);
|
||||
SEEN.lock().unwrap().get_or_insert_with(HashMap::new).insert(key, (reach, std::time::Instant::now()));
|
||||
if reach { None } else { Some(hand) }
|
||||
}
|
||||
|
||||
/// Send a model request (POST, JSON) through the hands; gives back its status and whole body.
|
||||
pub fn model_via_hands(hand: &str, url: &str, headers: &[(String, String)], body: &str) -> Result<(u16, Vec<u8>), String> {
|
||||
let args = json!({"url": url, "headers": headers, "body": body}).to_string();
|
||||
let req = json!({"tool": MODEL_FETCH, "args": args, "who": "大腦"}).to_string();
|
||||
let name = peer_name(hand);
|
||||
let whom = if name.is_empty() { "你的電腦".to_string() } else { format!("「{name}」") };
|
||||
let r = crate::tunnel::call(hand, "POST", "/api/device/run", &[], req.as_bytes(), 600).ok_or_else(|| format!("模型在只有{whom}連得到的網路上,但大腦現在和帳號伺服器沒有連線"))?;
|
||||
let a = r.map_err(|e| format!("透過{whom}連模型失敗:{e}"))?;
|
||||
let v = answer_value(a.status, &a.body, &whom)?;
|
||||
if let Some(e) = v["error"].as_str() {
|
||||
return Err(format!("{whom}也連不到模型:{e}"));
|
||||
}
|
||||
Ok((v["status"].as_u64().unwrap_or(502) as u16, v["text"].as_str().unwrap_or("").as_bytes().to_vec()))
|
||||
}
|
||||
|
||||
/// On the hands: make the brain's model request, only to an address on this computer's network.
|
||||
fn model_fetch_here(args: &str) -> Resp {
|
||||
let v: Value = serde_json::from_str(args).unwrap_or(Value::Null);
|
||||
let url = v["url"].as_str().unwrap_or("");
|
||||
if !host_port(url).map(|(h, _)| private_host(&h)).unwrap_or(false) {
|
||||
return Resp::error(403, "只幫大腦連這台電腦網路裡的模型");
|
||||
}
|
||||
let mut r = agent(600).post(url).header("Content-Type", "application/json");
|
||||
for h in v["headers"].as_array().into_iter().flatten() {
|
||||
if let (Some(k), Some(val)) = (h[0].as_str(), h[1].as_str()) {
|
||||
r = r.header(k, val);
|
||||
}
|
||||
}
|
||||
match r.config().http_status_as_error(false).build().send(v["body"].as_str().unwrap_or("").as_bytes()) {
|
||||
Ok(mut resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let text = resp.body_mut().with_config().limit(50 * 1024 * 1024).read_to_string().unwrap_or_default();
|
||||
Resp::json(&json!({"status": status, "text": text}))
|
||||
}
|
||||
Err(e) => Resp::json(&json!({"error": why_unreachable(&e)})),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- the local server ----------
|
||||
|
||||
static LAN_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
|
|
@ -779,6 +883,11 @@ fn base(url: &str) -> String {
|
|||
/// Sign in (or create the account) at the relay and register this AnyBot as one of its hosts.
|
||||
/// Returns the settings to keep.
|
||||
pub fn relay_join(base_settings: &Settings, url: &str, user: &str, password: &str, create: bool, invite: &str) -> Result<Settings, String> {
|
||||
// The relay signs people in by the trimmed, lowered name; the backup key must be derived
|
||||
// from exactly the same string, or two computers signing in as "Dan" and "dan" get keys
|
||||
// that cannot open each other's backups.
|
||||
let user = user.trim().to_lowercase();
|
||||
let user = user.as_str();
|
||||
let host_name = base_settings.host_name.as_str();
|
||||
let url = base(url);
|
||||
// Plain http only inside one machine or one Docker network (e.g. http://relay:8080).
|
||||
|
|
@ -931,6 +1040,9 @@ pub fn backup_now(s: &Settings, state_json: &[u8]) -> Result<usize, String> {
|
|||
|
||||
/// Download and open the newest backup of this account (for a new computer).
|
||||
pub fn restore(url: &str, user: &str, password: &str) -> Result<Vec<u8>, String> {
|
||||
// Same name shape the relay (and relay_join) use for the backup key.
|
||||
let user = user.trim().to_lowercase();
|
||||
let user = user.as_str();
|
||||
let url = base(url);
|
||||
let v = relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": "還原備份"}))?;
|
||||
let session = v["token"].as_str().unwrap_or("").to_string();
|
||||
|
|
@ -1222,12 +1334,39 @@ fn hands_ids() -> &'static Mutex<std::collections::HashSet<String>> {
|
|||
H.get_or_init(Mutex::default)
|
||||
}
|
||||
|
||||
/// The account's computers as last seen: id → (name, the brain it uses, lends its hands).
|
||||
fn peer_rows() -> &'static Mutex<HashMap<String, (String, String, bool)>> {
|
||||
static P: OnceLock<Mutex<HashMap<String, (String, String, bool)>>> = OnceLock::new();
|
||||
P.get_or_init(Mutex::default)
|
||||
}
|
||||
|
||||
/// Computers of the account that lend their hands to this brain (`me`), by id.
|
||||
pub fn my_hands(me: &str) -> Vec<String> {
|
||||
if me.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let mut v: Vec<String> = peer_rows().lock().unwrap().iter().filter(|(_, (_, uses, hands))| *hands && uses == me).map(|(id, _)| id.clone()).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
/// A computer's own name as the account server knows it.
|
||||
pub fn peer_name(id: &str) -> String {
|
||||
peer_rows().lock().unwrap().get(id).map(|r| r.0.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn devices_online(s: &Settings) -> Vec<(String, String, bool)> {
|
||||
if s.relay_url.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let Ok(mut r) = agent(10).get(&format!("{}/relay/host/peers", s.relay_url)).header("Authorization", &format!("Bearer {}", s.host_token)).call() else { return vec![] };
|
||||
let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null);
|
||||
*peer_rows().lock().unwrap() = v["hosts"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|h| Some((h["id"].as_str()?.to_string(), (h["name"].as_str().unwrap_or("").to_string(), h["uses"].as_str().unwrap_or("").to_string(), h["hands"].as_bool() == Some(true)))))
|
||||
.collect();
|
||||
*hands_ids().lock().unwrap() = v["hosts"].as_array().into_iter().flatten().filter(|h| h["hands"].as_bool() == Some(true) || !h["uses"].as_str().unwrap_or("").is_empty()).filter_map(|h| h["id"].as_str().map(String::from)).collect();
|
||||
v["hosts"].as_array().map(|a| a.iter().map(|h| (h["id"].as_str().unwrap_or("").to_string(), h["name"].as_str().unwrap_or("").to_string(), h["online"].as_bool().unwrap_or(false))).collect()).unwrap_or_default()
|
||||
}
|
||||
|
|
@ -1246,6 +1385,61 @@ pub fn page() -> Resp {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn model_requests_go_only_to_private_addresses() {
|
||||
assert!(private_host("10.184.10.252") && private_host("192.168.1.5") && private_host("localhost") && private_host("nas.local"));
|
||||
assert!(!private_host("api.x.ai") && !private_host("8.8.8.8"));
|
||||
// A cloud VM's metadata service answers on link-local: the hands must never reach it.
|
||||
assert!(!private_host("169.254.169.254") && !private_host("169.254.0.1"));
|
||||
assert_eq!(host_port("http://10.184.10.252:8000/v1"), Some(("10.184.10.252".into(), 8000)));
|
||||
assert_eq!(host_port("https://api.x.ai/v1"), Some(("api.x.ai".into(), 443)));
|
||||
assert_eq!(model_fetch_here(&json!({"url": "https://api.x.ai/v1/chat/completions"}).to_string()).status, 403);
|
||||
assert_eq!(model_fetch_here(&json!({"url": "http://169.254.169.254/latest/meta-data/"}).to_string()).status, 403);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lan_login_lock_is_per_device() {
|
||||
// One noisy device guessing passwords must not lock the owner's phone out (a single
|
||||
// global counter used to do exactly that).
|
||||
let mut s = Settings { password: httpd::hash_password("pw-123"), ..Default::default() };
|
||||
s.host_name = "測試 Mac".into();
|
||||
hub().set_settings(&s);
|
||||
let bad = |ip: &str| handle(Req { method: "POST".into(), path: "/api/login".into(), body: br#"{"password":"nope"}"#.to_vec(), peer: ip.into(), ..Default::default() }, false);
|
||||
for _ in 0..MAX_FAILS {
|
||||
assert_eq!(bad("192.168.1.66:5000").status, 401);
|
||||
}
|
||||
// The noisy device is now locked out…
|
||||
assert_eq!(bad("192.168.1.66:5000").status, 429);
|
||||
// …while every other device on the LAN can still sign in.
|
||||
let ok = handle(Req { method: "POST".into(), path: "/api/login".into(), body: br#"{"password":"pw-123"}"#.to_vec(), peer: "192.168.1.77:5001".into(), ..Default::default() }, false);
|
||||
assert_eq!(ok.status, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roles_gate_the_sync_and_hands_apis() {
|
||||
// 大腦 sync (mirror) and 裝置連結 (device) are separate doors on the hands machine:
|
||||
// a chat admin must not reach either, and a brain may not run tools through the sync door.
|
||||
assert!(role_allows("device", "POST", &["api", "device", "run"]));
|
||||
assert!(!role_allows("device", "GET", &["api", "sync"]));
|
||||
assert!(role_allows("mirror", "GET", &["api", "sync"]));
|
||||
assert!(role_allows("mirror", "POST", &["api", "sync", "edit"]));
|
||||
assert!(!role_allows("mirror", "POST", &["api", "device", "run"]));
|
||||
assert!(role_allows("admin", "POST", &["api", "bots", "1", "send"]));
|
||||
assert!(!role_allows("admin", "POST", &["api", "device", "run"]));
|
||||
assert!(!role_allows("admin", "GET", &["api", "sync"]));
|
||||
assert!(role_allows("member", "POST", &["api", "bots", "1", "send"]));
|
||||
assert!(!role_allows("member", "POST", &["api", "bots", "1", "stop"]));
|
||||
|
||||
// A hands machine takes tool orders only through the relay, only when the person opened
|
||||
// it (裝置連結), and only from the brain it was told to serve.
|
||||
let open_for_one = Settings { device_link: true, hands_for: "brain1".into(), hands_for_name: "雲端大腦".into(), ..Default::default() };
|
||||
assert_eq!(device_run(&json!({"tool": "x"}), &open_for_one, false, "brain1").status, 403);
|
||||
assert_eq!(device_run(&json!({"tool": "x"}), &Settings { device_link: false, hands_for: "brain1".into(), ..Default::default() }, true, "brain1").status, 403);
|
||||
assert_eq!(device_run(&json!({"tool": "x"}), &open_for_one, true, "other-brain").status, 403);
|
||||
// The chosen brain reaches past the gates (and gets "this tool can't run remotely" for x).
|
||||
assert_eq!(device_run(&json!({"tool": "x"}), &open_for_one, true, "brain1").status, 400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phone_login_and_api() {
|
||||
let mut s = Settings { password: httpd::hash_password("pw-123"), ..Default::default() };
|
||||
|
|
|
|||
|
|
@ -130,6 +130,26 @@ pub fn validate(mut s: Schedule) -> Result<Schedule, String> {
|
|||
if s.frequency == Frequency::Minutely && s.interval < MIN_MINUTES {
|
||||
return Err(format!("每 N 分鐘最少 {MIN_MINUTES} 分鐘"));
|
||||
}
|
||||
// Keep the "next run" maths (which searches interval-sized steps) bounded, and absurd
|
||||
// intervals out: a huge hourly interval used to spin the UI thread for minutes on create.
|
||||
let cap: u32 = match s.frequency {
|
||||
Frequency::Minutely => 7 * 24 * 60,
|
||||
Frequency::Hourly => 31 * 24,
|
||||
Frequency::Daily => 366,
|
||||
Frequency::Weekly => 53,
|
||||
Frequency::Monthly => 120,
|
||||
Frequency::Yearly => 10,
|
||||
};
|
||||
if s.interval > cap {
|
||||
return Err(format!("間隔太大(這種頻率最多每 {cap} 單位一次)"));
|
||||
}
|
||||
if s.frequency == Frequency::Yearly {
|
||||
let (m, d) = (s.month.unwrap_or(1), s.month_day.unwrap_or(1));
|
||||
// 2 月 31 日永遠不會來:這個排程建立即失效,當初卻毫無警告。
|
||||
if chrono::NaiveDate::from_ymd_opt(2024, m, d).is_none() {
|
||||
return Err(format!("每年 {m} 月 {d} 日不存在,無法排入"));
|
||||
}
|
||||
}
|
||||
match s.frequency {
|
||||
Frequency::Weekly if s.days_of_week.is_empty() => return Err("每週的例行任務要指定星期幾(daysOfWeek,例如 [\"MO\",\"WE\"])".into()),
|
||||
Frequency::Monthly if s.month_day.is_none() && s.weekday_of_month.is_none() => s.month_day = Some(1),
|
||||
|
|
@ -245,7 +265,8 @@ fn day_matches(s: &Schedule, anchor: NaiveDate, d: NaiveDate) -> bool {
|
|||
pub fn next_run(s: &Schedule, created: i64, after: i64) -> Option<i64> {
|
||||
if s.once {
|
||||
let first = exact_next(s, created, created - 1)?;
|
||||
let first = flex(s, created, first);
|
||||
// A one-time run is never flexed: flexing its only moment into the past made the task
|
||||
// born already-late and it died silently (next_run = None).
|
||||
return (first > after).then_some(first);
|
||||
}
|
||||
// Look a little earlier so a time pulled forward by "左右" isn't skipped.
|
||||
|
|
@ -280,10 +301,16 @@ fn exact_next(s: &Schedule, created: i64, after: i64) -> Option<i64> {
|
|||
}
|
||||
if s.frequency == Frequency::Hourly {
|
||||
let minute = parse_time(&s.time).map(|t| chrono::Timelike::minute(&t) as i64).unwrap_or(0);
|
||||
let hour0 = created.div_euclid(3600);
|
||||
let mut h = after.div_euclid(3600);
|
||||
// Count hours in the schedule's own timezone: its hour line sits at a different
|
||||
// epoch minute in +05:30/+05:45 — anchoring hours to UTC ran those zones 30/45 late.
|
||||
let tz = tz_of(&s.timezone);
|
||||
let at = Utc.timestamp_opt(created, 0).single()?.with_timezone(&tz);
|
||||
// Offset seconds of that zone at `created` (local wall clock minus UTC).
|
||||
let off = (at.naive_local() - at.naive_utc()).num_seconds() as i64;
|
||||
let hour0 = (created + off).div_euclid(3600);
|
||||
let mut h = (after + off).div_euclid(3600);
|
||||
for _ in 0..(n * 2 + 2) {
|
||||
let ts = h * 3600 + minute * 60;
|
||||
let ts = h * 3600 + minute * 60 - off;
|
||||
if ts > after && (h - hour0).rem_euclid(n) == 0 {
|
||||
return Some(ts);
|
||||
}
|
||||
|
|
@ -300,8 +327,17 @@ fn exact_next(s: &Schedule, created: i64, after: i64) -> Option<i64> {
|
|||
if !day_matches(s, anchor, d) {
|
||||
continue;
|
||||
}
|
||||
// Skipped or doubled clock times (summer time) take the earliest valid moment.
|
||||
let Some(at) = tz.from_local_datetime(&d.and_time(time)).earliest() else { continue };
|
||||
// Skipped or doubled clock times (summer time): a doubled time takes its first
|
||||
// occurrence; a skipped one (spring-forward) runs just after the gap, not never.
|
||||
let local = d.and_time(time);
|
||||
let at = match tz.from_local_datetime(&local) {
|
||||
chrono::LocalResult::Single(at) => at,
|
||||
chrono::LocalResult::Ambiguous(at, _) => at,
|
||||
chrono::LocalResult::None => match tz.from_local_datetime(&(local + Duration::hours(1))).earliest() {
|
||||
Some(at) => at,
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
let ts = at.timestamp();
|
||||
if ts > after {
|
||||
return Some(ts);
|
||||
|
|
|
|||
35
src/setup.rs
35
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
82
src/teach.rs
82
src/teach.rs
|
|
@ -39,6 +39,7 @@ static EVENTS: Mutex<Vec<Raw>> = Mutex::new(Vec::new());
|
|||
static EVENT_FRAMES: Mutex<Vec<usize>> = Mutex::new(Vec::new());
|
||||
static FRAMES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
static FRAME_STOP: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
static FRAME_HANDLE: Mutex<Option<std::thread::JoinHandle<()>>> = Mutex::new(None);
|
||||
static REC_DIR: Mutex<Option<std::path::PathBuf>> = Mutex::new(None);
|
||||
/// Screen pictures per second while recording (estimated; the original records video).
|
||||
pub const FRAMES_PER_SEC: u64 = 2;
|
||||
|
|
@ -59,6 +60,10 @@ pub fn start(bot_name: &str) -> Result<(), String> {
|
|||
}
|
||||
EVENTS.lock().unwrap().clear();
|
||||
EVENT_FRAMES.lock().unwrap().clear();
|
||||
// Zero the frame counter before the listener starts: events that arrive in the first
|
||||
// half second used to carry the *previous* recording's frame numbers (start_frames
|
||||
// cleared it only after mac::start), mapping steps to pictures from an old demo.
|
||||
FRAMES.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
mac::start()?;
|
||||
*RECORDING.lock().unwrap() = Some((bot_name.to_string(), Instant::now()));
|
||||
start_frames();
|
||||
|
|
@ -78,7 +83,7 @@ fn start_frames() {
|
|||
*REC_DIR.lock().unwrap() = Some(dir.clone());
|
||||
FRAMES.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
FRAME_STOP.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
std::thread::spawn(move || {
|
||||
*FRAME_HANDLE.lock().unwrap() = Some(std::thread::spawn(move || {
|
||||
while !FRAME_STOP.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
let t0 = Instant::now();
|
||||
if crate::computer::screen_supported() && crate::computer::screen_capture_ok() {
|
||||
|
|
@ -98,16 +103,38 @@ fn start_frames() {
|
|||
std::thread::sleep(rest);
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
/// Stop the pictures and turn them into a video when ffmpeg is there. Returns (dir, video).
|
||||
fn stop_frames() -> Option<(std::path::PathBuf, String)> {
|
||||
FRAME_STOP.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
std::thread::sleep(std::time::Duration::from_millis(700));
|
||||
// Let the capturing thread finish its current picture and exit (it ends within a second).
|
||||
// The old fixed 700 ms sleep raced slow captures (dirty files after the video was made)
|
||||
// and froze the window for its whole duration.
|
||||
if let Some(h) = FRAME_HANDLE.lock().unwrap().take() {
|
||||
let _ = h.join();
|
||||
}
|
||||
let dir = REC_DIR.lock().unwrap().take()?;
|
||||
let video = make_video(&dir).unwrap_or_default();
|
||||
Some((dir, video))
|
||||
// Encoding runs off the window thread (ffmpeg on a long demo takes seconds; the window
|
||||
// used to freeze for all of it). The mp4 file appears when it is done.
|
||||
if dir.join("frame_00000.jpg").exists() {
|
||||
let d = dir.clone();
|
||||
std::thread::spawn(move || {
|
||||
if make_video(&d).is_some() {
|
||||
// The video holds the motion; drop the single pictures it consumed.
|
||||
if let Ok(rd) = std::fs::read_dir(&d) {
|
||||
for e in rd.flatten() {
|
||||
if e.file_name().to_string_lossy().starts_with("frame_") {
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return Some((dir.clone(), dir.join("recording.mp4").display().to_string()));
|
||||
}
|
||||
Some((dir, String::new()))
|
||||
}
|
||||
|
||||
/// frame_*.jpg → recording.mp4 (H.264, like the original's render proxy).
|
||||
|
|
@ -134,6 +161,9 @@ pub fn make_video(dir: &std::path::Path) -> Option<String> {
|
|||
/// Stop and write up the steps.
|
||||
pub fn finish(name: &str) -> Demo {
|
||||
mac::stop();
|
||||
// The listener stops asynchronously (the X11 one polls every 150 ms): give the last
|
||||
// events of the demo time to arrive — they used to be cut off the recording.
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
*RECORDING.lock().unwrap() = None;
|
||||
let raw = std::mem::take(&mut *EVENTS.lock().unwrap());
|
||||
let frames_at = std::mem::take(&mut *EVENT_FRAMES.lock().unwrap());
|
||||
|
|
@ -150,16 +180,7 @@ pub fn finish(name: &str) -> Demo {
|
|||
let dst = dir.join(format!("step_{:02}.jpg", i + 1));
|
||||
shots.push(if std::fs::copy(&src, &dst).is_ok() { dst.display().to_string() } else { String::new() });
|
||||
}
|
||||
// The video holds the rest.
|
||||
if !video.is_empty() {
|
||||
if let Ok(rd) = std::fs::read_dir(dir) {
|
||||
for e in rd.flatten() {
|
||||
if e.file_name().to_string_lossy().starts_with("frame_") {
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The frame pictures are removed by the encoder thread once the video is written.
|
||||
}
|
||||
Demo { name: name.trim().to_string(), created: chrono::Local::now().format("%Y-%m-%d %H:%M").to_string(), steps: compiled.into_iter().map(|c| c.0).collect(), video, shots }
|
||||
}
|
||||
|
|
@ -382,6 +403,21 @@ mod mac {
|
|||
}
|
||||
|
||||
static TAP: Mutex<usize> = Mutex::new(0);
|
||||
// The foreground app barely moves between keystrokes, and the window-list query is the
|
||||
// slowest call in the tap callback — slow callbacks make macOS disable the tap (events
|
||||
// lost until it is re-enabled). Answer keystrokes from a short cache.
|
||||
static FRONT_CACHE: Mutex<Option<(Instant, String)>> = Mutex::new(None);
|
||||
fn front_cached() -> String {
|
||||
let mut c = FRONT_CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((t, n)) = c.as_ref() {
|
||||
if t.elapsed() < std::time::Duration::from_millis(400) {
|
||||
return n.clone();
|
||||
}
|
||||
}
|
||||
let n = crate::computer::frontmost_app().unwrap_or_default();
|
||||
*c = Some((Instant::now(), n.clone()));
|
||||
n
|
||||
}
|
||||
|
||||
extern "C" fn callback(_proxy: *mut c_void, kind: u32, e: *mut c_void, _user: *mut c_void) -> *mut c_void {
|
||||
unsafe {
|
||||
|
|
@ -397,7 +433,7 @@ mod mac {
|
|||
CGEventKeyboardGetUnicodeString(e, buf.len(), &mut n, buf.as_mut_ptr());
|
||||
let text = String::from_utf16_lossy(&buf[..n.min(buf.len())]);
|
||||
let code = CGEventGetIntegerValueField(e, 9);
|
||||
let app = crate::computer::frontmost_app().unwrap_or_default();
|
||||
let app = front_cached();
|
||||
push(Raw::Key { text, code, flags: CGEventGetFlags(e), app });
|
||||
}
|
||||
22 => {
|
||||
|
|
@ -445,7 +481,7 @@ mod mac {
|
|||
match rx.recv_timeout(std::time::Duration::from_secs(3)) {
|
||||
Ok(true) => Ok(()),
|
||||
_ => {
|
||||
let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent").spawn();
|
||||
let _ = crate::computer::spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"));
|
||||
Err("macOS 還沒讓 AnyBot 觀看你的操作。我已經打開「系統設定 → 隱私權與安全性 → 輸入監控」:把 AnyBot(或「終端機」)打開,再按一次「開始錄製」。".into())
|
||||
}
|
||||
}
|
||||
|
|
@ -491,7 +527,17 @@ mod mac {
|
|||
let _ = tx.send(Err(e));
|
||||
}
|
||||
});
|
||||
rx.recv_timeout(std::time::Duration::from_secs(3)).unwrap_or(Err("沒辦法開始錄製(X11 沒有回應)".into()))
|
||||
// On a timeout the record thread may still be spinning up: close the gate so its
|
||||
// keep_going() ends it — a thread left running meant a second RECORD context
|
||||
// (double events) on the next try, plus a leaked X connection.
|
||||
match rx.recv_timeout(std::time::Duration::from_secs(3)) {
|
||||
Ok(Ok(())) => Ok(()),
|
||||
Ok(Err(e)) => Err(e),
|
||||
Err(_) => {
|
||||
STOP.store(true, Ordering::SeqCst);
|
||||
Err("沒辦法開始錄製(X11 沒有回應)".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// X input → the same events the macOS tap gives (key codes and flags in macOS terms, so
|
||||
|
|
|
|||
130
src/tools.rs
130
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<String> = Vec::new();
|
||||
for w in words(part) {
|
||||
let mut w = w;
|
||||
while matches!(w.as_bytes().first(), Some(b'(') | Some(b'{')) {
|
||||
w = &w[1..];
|
||||
}
|
||||
if w.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// VAR=val prefixes set the environment; they are not the command.
|
||||
let is_assign = w.split_once('=').map(|(k, v)| !k.is_empty() && v.len() + 1 < w.len() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')).unwrap_or(false);
|
||||
if toks.is_empty() && is_assign {
|
||||
continue;
|
||||
}
|
||||
toks.push(w.to_string());
|
||||
}
|
||||
const WRAP: [&str; 7] = ["env", "command", "nohup", "time", "nice", "stdbuf", "timeout"];
|
||||
while toks.first().map(|t| WRAP.contains(&t.as_str())).unwrap_or(false) {
|
||||
toks.remove(0);
|
||||
while toks.first().map(|t| t.starts_with('-')).unwrap_or(false) {
|
||||
toks.remove(0);
|
||||
}
|
||||
// timeout takes a duration before the command.
|
||||
if toks.first().map(|t| !t.is_empty() && t.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false)).unwrap_or(false) {
|
||||
toks.remove(0);
|
||||
}
|
||||
}
|
||||
let joined = toks.join(" ");
|
||||
DENIED_COMMANDS.iter().find(|d| starts_with_words(&joined, d)).copied()
|
||||
}
|
||||
|
||||
/// The "always allow" grant for a command: its first word, or first two for git/npm/cargo-style tools.
|
||||
pub fn shell_grant(part: &str) -> String {
|
||||
let w = words(part);
|
||||
|
|
@ -296,7 +330,7 @@ pub fn decide(name: &str, args: &str, workdir: &Path, grants: &[String]) -> Deci
|
|||
};
|
||||
// deny
|
||||
for part in &parts {
|
||||
if let Some(d) = DENIED_COMMANDS.iter().find(|d| starts_with_words(part, d)) {
|
||||
if let Some(d) = denied_hit(part) {
|
||||
return Decision::Deny(format!("「{d}」這類指令一律不允許。"));
|
||||
}
|
||||
}
|
||||
|
|
@ -614,10 +648,10 @@ fn ssh_command(target: &str, dir: &str, cmd: &str) -> String {
|
|||
}
|
||||
|
||||
/// The local wrapper that runs `cmd` on a remote computer (an SSH host or the bot's box).
|
||||
fn wrapper_for(place: &Place, cmd: &str, interactive: bool) -> Option<String> {
|
||||
fn wrapper_for(place: &Place, cmd: &str, interactive: bool, job: &str) -> Option<String> {
|
||||
match place {
|
||||
Place::Ssh { target, dir } => Some(ssh_command(target, dir, cmd)),
|
||||
Place::Box { dir } => Some(crate::cloudbox::exec_wrapper(dir, cmd, interactive)),
|
||||
Place::Box { dir } => Some(crate::cloudbox::exec_wrapper(dir, cmd, interactive, job)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -680,7 +714,7 @@ pub fn decide_on(name: &str, args: &str, target: &Target, grants: &[String]) ->
|
|||
/// Run a remote command and give back its output (no background hand-off).
|
||||
fn remote_capture(place: &Place, cmd: &str, stdin: Option<&[u8]>) -> Result<String, String> {
|
||||
use std::io::Write;
|
||||
let w = wrapper_for(place, cmd, stdin.is_some()).ok_or("不是遠端電腦")?;
|
||||
let w = wrapper_for(place, cmd, stdin.is_some(), "").ok_or("不是遠端電腦")?;
|
||||
let mut c = build_wrapper_command(&w, "", "");
|
||||
let mut child = c.spawn().map_err(|e| format!("無法執行:{e}"))?;
|
||||
if let Some(data) = stdin {
|
||||
|
|
@ -732,6 +766,16 @@ pub fn run_on(name: &str, args: &str, target: &Target, cancel: &AtomicBool, ids:
|
|||
if v.is_null() && !args.trim().is_empty() {
|
||||
return ("參數不是有效的 JSON".into(), false);
|
||||
}
|
||||
// Box file tools stay inside this bot's own folder: every bot shares one container,
|
||||
// one user and one disk, so `../` or an absolute path reached (and rewrote) the
|
||||
// other bots' files — a planted .bashrc runs code on their next command.
|
||||
let (p, path) = match place {
|
||||
Place::Box { dir } if name != "shell" => match box_path(std::path::Path::new(dir), &p) {
|
||||
Ok(s) => (s.clone(), s),
|
||||
Err(e) => return (e, false),
|
||||
},
|
||||
_ => (p, path),
|
||||
};
|
||||
let remote = match name {
|
||||
"shell" if arg_str(&v, "command").trim().is_empty() => return ("請給要執行的指令".into(), false),
|
||||
"shell" => arg_str(&v, "command"),
|
||||
|
|
@ -774,12 +818,45 @@ pub fn run_on(name: &str, args: &str, target: &Target, cancel: &AtomicBool, ids:
|
|||
"read_terminal" | "write_terminal" => return run(name, args, Path::new("/"), cancel, ids),
|
||||
other => return (format!("沒有「{other}」這個工具"), false),
|
||||
};
|
||||
let wrapper = wrapper_for(place, &remote, name == "shell").unwrap_or_default();
|
||||
done(shell_wrapped(&wrapper, &remote, timeout_of(&v), cancel, ids))
|
||||
// Tag the in-container command so stopping it here also stops it inside the box
|
||||
// (killing the local docker client alone left the program running in the box).
|
||||
let job = crate::httpd::random_hex(8);
|
||||
let wrapper = wrapper_for(place, &remote, name == "shell", &job).unwrap_or_default();
|
||||
let kill = matches!(place, Place::Box { .. }).then(|| crate::cloudbox::exec_kill_argv(&job));
|
||||
done(shell_wrapped(&wrapper, &remote, timeout_of(&v), cancel, ids, kill))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep a Box file path inside the bot's own folder: relative paths join the bot's dir, and
|
||||
/// a normalised result that lands outside it (`../`, absolute paths to other bots' folders)
|
||||
/// is refused. All bots share one container and one user, so the folder is the only wall.
|
||||
pub fn box_path(dir: &Path, p: &str) -> Result<String, String> {
|
||||
let joined = if p.starts_with('/') { PathBuf::from(p) } else { dir.join(p) };
|
||||
let mut parts: Vec<std::ffi::OsString> = vec![];
|
||||
for c in joined.components() {
|
||||
use std::path::Component::*;
|
||||
match c {
|
||||
ParentDir => {
|
||||
parts.pop();
|
||||
}
|
||||
CurDir => {}
|
||||
RootDir => parts.push(std::ffi::OsString::from("/")),
|
||||
Normal(x) => parts.push(x.to_os_string()),
|
||||
Prefix(x) => parts.push(x.as_os_str().to_os_string()),
|
||||
}
|
||||
}
|
||||
let mut norm = PathBuf::new();
|
||||
for x in parts {
|
||||
norm.push(x);
|
||||
}
|
||||
if norm.starts_with(dir) {
|
||||
Ok(norm.display().to_string())
|
||||
} else {
|
||||
Err(format!("這個檔案不在你的資料夾裡(Bot 的電腦上每個 Bot 只用自己的資料夾):{p}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a tool that the rules already allowed. Returns (output, ok).
|
||||
pub fn run(name: &str, args: &str, workdir: &Path, cancel: &AtomicBool, ids: &RunIds) -> (String, bool) {
|
||||
let v = args_of(args);
|
||||
|
|
@ -977,15 +1054,15 @@ fn edit_file(p: &Path, old: &str, new: &str) -> Result<String, String> {
|
|||
}
|
||||
|
||||
fn shell(cmd: &str, workdir: &Path, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, seal: Option<&Path>) -> Result<String, String> {
|
||||
run_command(build_command_sealed(cmd, workdir, &protected_dirs(), &readable_dirs(), seal, &ids.conversation, &ids.request), cmd, timeout_ms, cancel, ids)
|
||||
run_command(build_command_sealed(cmd, workdir, &protected_dirs(), &readable_dirs(), seal, &ids.conversation, &ids.request), cmd, timeout_ms, cancel, ids, None)
|
||||
}
|
||||
|
||||
/// Run a wrapper command (ssh / docker exec) the same way: background hand-off, stop, output.
|
||||
pub fn shell_wrapped(wrapper: &str, shown: &str, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds) -> Result<String, String> {
|
||||
run_command(build_wrapper_command(wrapper, &ids.conversation, &ids.request), shown, timeout_ms, cancel, ids)
|
||||
pub fn shell_wrapped(wrapper: &str, shown: &str, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, kill: Option<Vec<String>>) -> Result<String, String> {
|
||||
run_command(build_wrapper_command(wrapper, &ids.conversation, &ids.request), shown, timeout_ms, cancel, ids, kill)
|
||||
}
|
||||
|
||||
fn run_command(mut command: Command, cmd: &str, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds) -> Result<String, String> {
|
||||
fn run_command(mut command: Command, cmd: &str, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, kill: Option<Vec<String>>) -> Result<String, String> {
|
||||
let mut child = command.spawn().map_err(|e| format!("無法執行:{e}"))?;
|
||||
let stdin = Arc::new(Mutex::new(child.stdin.take()));
|
||||
let root = child.id();
|
||||
|
|
@ -999,7 +1076,11 @@ fn run_command(mut command: Command, cmd: &str, timeout_ms: u64, cancel: &Atomic
|
|||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
out.lock().unwrap().push_str(&String::from_utf8_lossy(&chunk[..n]));
|
||||
// Cap the kept output (keep the newest): `yes` must not eat all the RAM.
|
||||
let mut out = out.lock().unwrap();
|
||||
if out.len() < 4_000_000 {
|
||||
out.push_str(&String::from_utf8_lossy(&chunk[..n]));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1014,6 +1095,14 @@ fn run_command(mut command: Command, cmd: &str, timeout_ms: u64, cancel: &Atomic
|
|||
if cancel.load(Ordering::Relaxed) {
|
||||
kill_tree(root);
|
||||
let _ = child.kill();
|
||||
// Reap it: a killed child nobody waits for stays a zombie for the app's lifetime.
|
||||
let _ = child.wait();
|
||||
// The remote end (a program running inside the box) is not in this process tree.
|
||||
if let Some(k) = &kill {
|
||||
if let Some((head, rest)) = k.split_first() {
|
||||
let _ = Command::new(head).args(rest).status();
|
||||
}
|
||||
}
|
||||
return Err("已停止(連同它啟動的程式)".into());
|
||||
}
|
||||
if start.elapsed() >= Duration::from_millis(timeout_ms) {
|
||||
|
|
@ -1054,12 +1143,17 @@ fn read_terminal(id: u64) -> Result<String, String> {
|
|||
/// Send input to a background command (like typing into its terminal). `close` ends its input.
|
||||
fn write_terminal(id: u64, input: &str, close: bool) -> Result<String, String> {
|
||||
use std::io::Write;
|
||||
// Copy the terminal's own handles out first: writing to a pipe whose buffer is full blocks,
|
||||
// and holding the shared registry lock while blocked stops every other terminal tool.
|
||||
let (stdin, exit) = {
|
||||
let t = terminals().lock().unwrap();
|
||||
let term = t.get(&id).ok_or_else(|| format!("沒有背景指令 #{id}"))?;
|
||||
if term.exit.lock().unwrap().is_some() {
|
||||
(term.stdin.clone(), term.exit.clone())
|
||||
};
|
||||
if exit.lock().unwrap().is_some() {
|
||||
return Err(format!("背景指令 #{id} 已經結束了"));
|
||||
}
|
||||
let mut slot = term.stdin.lock().unwrap();
|
||||
let mut slot = stdin.lock().unwrap();
|
||||
if !input.is_empty() {
|
||||
let w = slot.as_mut().ok_or("這個指令的輸入已經關閉了")?;
|
||||
w.write_all(input.as_bytes()).and_then(|_| w.flush()).map_err(|e| format!("送不進去:{e}"))?;
|
||||
|
|
@ -1068,7 +1162,6 @@ fn write_terminal(id: u64, input: &str, close: bool) -> Result<String, String> {
|
|||
*slot = None;
|
||||
}
|
||||
drop(slot);
|
||||
drop(t);
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
let out = read_terminal(id)?;
|
||||
Ok(format!("已送出{}{}。\n{out}", if input.is_empty() { String::new() } else { format!(" {} 個字", input.chars().count()) }, if close { ",並結束輸入" } else { "" }))
|
||||
|
|
@ -1077,9 +1170,12 @@ fn write_terminal(id: u64, input: &str, close: bool) -> Result<String, String> {
|
|||
/// Stop every background command and what it started (when the app quits).
|
||||
pub fn stop_all() {
|
||||
for t in terminals().lock().unwrap().values() {
|
||||
// child before exit — the watcher thread takes them in this order; the reverse order
|
||||
// here could wedge both threads forever (AB-BA).
|
||||
let mut child = t.child.lock().unwrap();
|
||||
if t.exit.lock().unwrap().is_none() {
|
||||
kill_tree(t.root);
|
||||
let _ = t.child.lock().unwrap().kill();
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1088,9 +1184,11 @@ pub fn stop_all() {
|
|||
pub fn stop_for(conversation: &str) -> usize {
|
||||
let mut n = 0;
|
||||
for t in terminals().lock().unwrap().values().filter(|t| t.conversation == conversation) {
|
||||
// Same lock order as the watcher thread (child, then exit).
|
||||
let mut child = t.child.lock().unwrap();
|
||||
if t.exit.lock().unwrap().is_none() {
|
||||
kill_tree(t.root);
|
||||
let _ = t.child.lock().unwrap().kill();
|
||||
let _ = child.kill();
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -315,9 +315,12 @@ pub fn github_runs(runs: &Value, seen: &mut HashSet<u64>, first: bool) -> Vec<Ev
|
|||
/// status changes. Events are `created` and `<status>`.
|
||||
pub fn status_changes(prev: &mut HashMap<String, String>, items: &[(String, String, String)], first: bool) -> Vec<(String, String)> {
|
||||
let mut out = vec![];
|
||||
// An empty prev is always the baseline: a poll that follows a failed first round must
|
||||
// record, not fire every existing item as "created" (history replay).
|
||||
let baseline = first || prev.is_empty();
|
||||
for (id, status, title) in items {
|
||||
match prev.get(id) {
|
||||
None if !first => out.push(("created".to_string(), title.clone())),
|
||||
None if !baseline => out.push(("created".to_string(), title.clone())),
|
||||
Some(old) if old != status => out.push((status.clone(), format!("{title}({old} → {status})"))),
|
||||
_ => {}
|
||||
}
|
||||
|
|
@ -437,6 +440,9 @@ fn sleep_alive(gen: u64, secs: u64) -> bool {
|
|||
pub fn restart(watches: Vec<Watch>, slack_token: String, tx: Sender<Fired>, repaint: Arc<dyn Fn() + Send + Sync>) {
|
||||
let gen = GENERATION.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
webhooks().lock().unwrap().clear();
|
||||
// Fresh router too: old entries held senders of the previous generation (a leak, and a
|
||||
// webhook could fire a routine that no longer exists).
|
||||
*ROUTER_TX.lock().unwrap() = None;
|
||||
for w in watches {
|
||||
start_one(w.bot_id, w.routine_id, w.trigger, gen, slack_token.clone(), tx.clone(), repaint.clone());
|
||||
}
|
||||
|
|
@ -497,6 +503,9 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire:
|
|||
let mut slack_own = String::new();
|
||||
let mut slack_reacted: HashSet<String> = HashSet::new();
|
||||
loop {
|
||||
// first must only advance after a round that actually reached the service: a failed
|
||||
// first round used to make the next one replay the whole history as new events.
|
||||
let mut reached = true;
|
||||
let wait = match &t {
|
||||
Trigger::Github { repo, token, events, users, branch, pr } => {
|
||||
let mut h = vec![("Accept", "application/vnd.github+json".to_string())];
|
||||
|
|
@ -515,7 +524,7 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire:
|
|||
cursor = next;
|
||||
note(routine_id, "已連上 GitHub,等待事件");
|
||||
}
|
||||
Err(e) => note(routine_id, &format!("GitHub:{e}")),
|
||||
Err(e) => { reached = false; note(routine_id, &format!("GitHub:{e}")) },
|
||||
}
|
||||
if wanted(events, "ci_passed") || wanted(events, "ci_failed") {
|
||||
if let Ok(v) = get(&format!("https://api.github.com/repos/{repo}/actions/runs?per_page=20"), &h) {
|
||||
|
|
@ -560,7 +569,7 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire:
|
|||
note(routine_id, "已連上 Slack,等待訊息");
|
||||
}
|
||||
Ok(v) => note(routine_id, &format!("Slack 說:{}", v["error"].as_str().unwrap_or("不明錯誤"))),
|
||||
Err(e) => note(routine_id, &format!("Slack:{e}")),
|
||||
Err(e) => { reached = false; note(routine_id, &format!("Slack:{e}")) },
|
||||
}
|
||||
SLACK_POLL_SECS
|
||||
}
|
||||
|
|
@ -583,7 +592,7 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire:
|
|||
}
|
||||
note(routine_id, "已連上 Linear,等待變化");
|
||||
}
|
||||
Err(e) => note(routine_id, &format!("Linear:{e}")),
|
||||
Err(e) => { reached = false; note(routine_id, &format!("Linear:{e}")) },
|
||||
}
|
||||
SERVICE_POLL_SECS
|
||||
}
|
||||
|
|
@ -620,7 +629,7 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire:
|
|||
}
|
||||
note(routine_id, "已連上 Sentry,等待變化");
|
||||
}
|
||||
Err(e) => note(routine_id, &format!("Sentry:{e}")),
|
||||
Err(e) => { reached = false; note(routine_id, &format!("Sentry:{e}")) },
|
||||
}
|
||||
SERVICE_POLL_SECS
|
||||
}
|
||||
|
|
@ -642,7 +651,7 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire:
|
|||
}
|
||||
note(routine_id, "已連上 PagerDuty,等待事故");
|
||||
}
|
||||
Err(e) => note(routine_id, &format!("PagerDuty:{e}")),
|
||||
Err(e) => { reached = false; note(routine_id, &format!("PagerDuty:{e}")) },
|
||||
}
|
||||
SERVICE_POLL_SECS
|
||||
}
|
||||
|
|
@ -663,6 +672,7 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire:
|
|||
}
|
||||
note(routine_id, "正在看「郵件」的收件匣");
|
||||
} else {
|
||||
reached = false;
|
||||
note(routine_id, &o);
|
||||
}
|
||||
SERVICE_POLL_SECS
|
||||
|
|
@ -679,13 +689,31 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire:
|
|||
}
|
||||
note(routine_id, "正在看「行事曆」");
|
||||
} else {
|
||||
reached = false;
|
||||
note(routine_id, &o);
|
||||
}
|
||||
CALENDAR_POLL_SECS
|
||||
}
|
||||
Trigger::Webhook { .. } | Trigger::Group { .. } => return,
|
||||
};
|
||||
if reached {
|
||||
first = false;
|
||||
}
|
||||
// These sets grow with every event seen: a watcher that runs for months must stay
|
||||
// bounded (ids grow, so keep the newest range; the react/calendar sets are only
|
||||
// short-term de-duplication, so a cap that clears is harmless).
|
||||
if seen.len() > 4096 {
|
||||
let m = *seen.iter().max().unwrap_or(&0);
|
||||
seen.retain(|id| *id + 100_000 >= m);
|
||||
}
|
||||
if prev.len() > 4096 {
|
||||
// status_changes treats an empty set as a quiet baseline (records, fires nothing),
|
||||
// so clearing is safe and keeps the map from growing with every issue ever seen.
|
||||
prev.clear();
|
||||
}
|
||||
if slack_reacted.len() > 4096 {
|
||||
slack_reacted.clear();
|
||||
}
|
||||
if !sleep_alive(gen, wait) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -238,6 +238,10 @@ fn keep(mut ws: WebSocket<Stream>, serial: u64, alive: &dyn Fn() -> bool) {
|
|||
Ok(Message::Binary(b)) => {
|
||||
heard = Instant::now();
|
||||
if let Some((head, body)) = wire::unpack(&b) {
|
||||
// The relay says this machine was unregistered (signed out): stop for good.
|
||||
if head["t"] == "bye" {
|
||||
break;
|
||||
}
|
||||
on_frame(head, body, &tx, &calls);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
src/vault.rs
15
src/vault.rs
|
|
@ -178,7 +178,10 @@ fn onepassword(host: &str) -> Option<Login> {
|
|||
return None;
|
||||
}
|
||||
let items: Value = serde_json::from_slice(&list.stdout).ok()?;
|
||||
let id = items.as_array()?.iter().find(|i| i["urls"].as_array().into_iter().flatten().any(|u| host_of(u["href"].as_str().unwrap_or("")).ends_with(host)))?["id"].as_str()?.to_string();
|
||||
// Match the host exactly or as a subdomain (with the dot): "notgoogle.com" must not
|
||||
// answer for "google.com" (a plain ends_with fills the wrong login on a lookalike domain).
|
||||
let host_match = |h: &str, want: &str| h == want || h.ends_with(&format!(".{want}"));
|
||||
let id = items.as_array()?.iter().find(|i| i["urls"].as_array().into_iter().flatten().any(|u| host_match(&host_of(u["href"].as_str().unwrap_or("")), host)))?["id"].as_str()?.to_string();
|
||||
let got = Command::new("op").env("PATH", &path).args(["item", "get", &id, "--fields", "label=username,label=password", "--reveal", "--format", "json"]).output().ok()?;
|
||||
let fields: Value = serde_json::from_slice(&got.stdout).ok()?;
|
||||
let arr = fields.as_array()?;
|
||||
|
|
@ -412,9 +415,6 @@ pub fn fill_login(plan: &LoginPlan, submit: bool) -> Result<String, String> {
|
|||
let (username, password, source) = match (&plan.item, &plan.other) {
|
||||
(Some(i), _) => {
|
||||
let (u, p) = crate::vault_store::login_secret(&i.id).ok_or("保險庫裡沒有這筆登入資訊")?;
|
||||
if !plan.matched {
|
||||
crate::vault_store::add_site(&i.id, &plan.host)?;
|
||||
}
|
||||
(u, p, "保險庫")
|
||||
}
|
||||
(None, Some(l)) => (l.username.clone(), l.password.clone(), l.source),
|
||||
|
|
@ -425,6 +425,13 @@ pub fn fill_login(plan: &LoginPlan, submit: bool) -> Result<String, String> {
|
|||
if got["password"].as_bool() != Some(true) && got["user"].as_bool() != Some(true) {
|
||||
return Err("這一頁找不到帳號或密碼欄位。先打開登入頁(有些網站要先按「登入」或先填帳號、按下一步才出現密碼欄)。".into());
|
||||
}
|
||||
// Remember the domain only after the fill worked: adding it on a failed (or lookalike)
|
||||
// page made the next visit show matched:true and skip the anti-phishing warning.
|
||||
if !plan.matched {
|
||||
if let Some(i) = &plan.item {
|
||||
crate::vault_store::add_site(&i.id, &plan.host)?;
|
||||
}
|
||||
}
|
||||
Ok(format!(
|
||||
"已在 {} 填入{}{}(來自{source},你看不到密碼)。{}",
|
||||
plan.host,
|
||||
|
|
|
|||
|
|
@ -381,8 +381,12 @@ fn store_key(key: &[u8]) -> Result<(), String> {
|
|||
fn key(create: bool) -> Result<aead::LessSafeKey, String> {
|
||||
let k = match read_key() {
|
||||
Some(k) if k.len() == 32 => k,
|
||||
_ if !create => return Err("找不到保險庫的金鑰(系統鑰匙圈裡的「AnyBot vault key」)".into()),
|
||||
_ => {
|
||||
// An entry exists but is not a key: generating a fresh one (and -U overwriting the
|
||||
// entry) would destroy the only copy of the old key — every saved password would be
|
||||
// undecryptable forever. Stop and tell the person instead.
|
||||
Some(_) => return Err("保險庫金鑰損毀:鑰匙圈裡的「AnyBot vault key」內容不正確。已停止動作,避免覆蓋後舊密碼永遠解不開;若你確定不需要舊密碼,可手動刪除該鑰匙圈項目再試。".into()),
|
||||
None if !create => return Err("找不到保險庫的金鑰(系統鑰匙圈裡的「AnyBot vault key」)".into()),
|
||||
None => {
|
||||
let mut k = vec![0u8; 32];
|
||||
SystemRandom::new().fill(&mut k).map_err(|_| "產生金鑰失敗")?;
|
||||
store_key(&k)?;
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
18
src/web.rs
18
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<Value> {
|
||||
pub fn specs_for(here: &str, devices: &[String]) -> Vec<Value> {
|
||||
let local = crate::botbrowser::available();
|
||||
let mut v = specs_inner(local || !devices.is_empty());
|
||||
if !devices.is_empty() {
|
||||
let mut names: Vec<String> = devices.to_vec();
|
||||
// A brain on a server with the person's computer as its hands: that one's browser first.
|
||||
let person = devices.iter().any(|d| d == crate::model::THIS_COMPUTER_NAME);
|
||||
if local {
|
||||
names.insert(0, crate::model::THIS_COMPUTER_NAME.to_string());
|
||||
names.insert(if person { names.len() } else { 0 }, here.to_string());
|
||||
}
|
||||
let desc = format!(
|
||||
"Which computer's AnyBot browser to use. Each computer's browser has its own logins (a site logged in on one isn't on another). {}",
|
||||
if local { format!("Leave out for {}.", crate::model::THIS_COMPUTER_NAME) } else { "This computer has no browser: always name one.".to_string() }
|
||||
if person {
|
||||
format!("Leave out for {} (the person's own computer, where they can see the window).", crate::model::THIS_COMPUTER_NAME)
|
||||
} else if local {
|
||||
format!("Leave out for {here}.")
|
||||
} else {
|
||||
"This computer has no browser: always name one.".to_string()
|
||||
}
|
||||
);
|
||||
for t in v.iter_mut().filter(|t| t["name"].as_str().map(|n| n.starts_with("browser_")).unwrap_or(false)) {
|
||||
t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": names, "description": desc});
|
||||
|
|
@ -170,7 +178,9 @@ pub fn normalize_url(u: &str) -> Result<String, String> {
|
|||
return Err("請給網址".into());
|
||||
}
|
||||
let full = if u.starts_with("http://") || u.starts_with("https://") { u.to_string() } else { format!("https://{u}") };
|
||||
if full.contains(char::is_whitespace) || !full[8..].contains('.') && !full.contains("localhost") {
|
||||
// Check the host part after the scheme (a fixed [8..] cut one byte short for http://).
|
||||
let rest = full.split_once("://").map(|(_, r)| r).unwrap_or(&full);
|
||||
if full.contains(char::is_whitespace) || !rest.contains('.') && !rest.starts_with("localhost") {
|
||||
return Err(format!("「{u}」看起來不是網址"));
|
||||
}
|
||||
Ok(full)
|
||||
|
|
|
|||
55
src/wl.rs
55
src/wl.rs
|
|
@ -92,8 +92,29 @@ static WL: Mutex<Option<Wl>> = Mutex::new(None);
|
|||
/// The four modifiers always sit on the first keys, with their xkb modifier bits.
|
||||
const MODS: [(u32, u32, &str); 4] = [(0xffe1, 1, "Shift"), (0xffe3, 4, "Control"), (0xffe9, 8, "Mod1"), (0xffeb, 64, "Mod4")];
|
||||
|
||||
/// Ask the kernel to give up a read on the compositor socket after `t`. A compositor that
|
||||
/// stops answering used to block `blocking_dispatch` forever while the app's global Wayland
|
||||
/// lock is held — every screen action (and the UI on top of it) froze. The timed-out read
|
||||
/// arrives as an error, so the connection is treated as dead and rebuilt on the next call.
|
||||
fn set_read_timeout(fd: std::os::fd::RawFd, t: Duration) -> std::io::Result<()> {
|
||||
let tv = libc::timeval { tv_sec: i64::try_from(t.as_secs()).unwrap_or(i64::MAX), tv_usec: 0 };
|
||||
let rc = unsafe {
|
||||
libc::setsockopt(fd, libc::SOL_SOCKET, libc::SO_RCVTIMEO, &tv as *const _ as *const libc::c_void, std::mem::size_of::<libc::timeval>() as libc::socklen_t)
|
||||
};
|
||||
if rc == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::last_os_error())
|
||||
}
|
||||
}
|
||||
|
||||
fn connect() -> Option<Wl> {
|
||||
let conn = Connection::connect_to_env().ok()?;
|
||||
// The compositor's socket: let the kernel give up on reads after 60 s (see
|
||||
// set_read_timeout). Timed-out reads arrive as errors, so the connection is treated
|
||||
// as dead and rebuilt on the next call.
|
||||
use std::os::fd::AsRawFd as _;
|
||||
set_read_timeout(conn.as_fd().as_raw_fd(), Duration::from_secs(60)).ok()?;
|
||||
let (globals, mut queue) = registry_queue_init::<State>(&conn).ok()?;
|
||||
let qh = queue.handle();
|
||||
let shm: wl_shm::WlShm = globals.bind(&qh, 1..=1, ()).ok()?;
|
||||
|
|
@ -230,20 +251,24 @@ impl Wl {
|
|||
fn capture_output(&mut self, out: &wl_output::WlOutput) -> Option<image::RgbaImage> {
|
||||
self.st.frame = Frame::default();
|
||||
let frame = self.copy.capture_output(0, out, &self.qh, ());
|
||||
// Every early exit passes through here, so the frame (and its buffer/pool) are always
|
||||
// destroyed: failure paths used to leak compositor-side objects on every failed grab.
|
||||
let r = self.grab(&frame);
|
||||
frame.destroy();
|
||||
r
|
||||
}
|
||||
|
||||
fn grab(&mut self, frame: &frame_v1::ZwlrScreencopyFrameV1) -> Option<image::RgbaImage> {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while !(self.st.frame.failed || (self.st.frame.buffer.is_some() && (self.st.frame.buffer_done || self.copy_version < 3))) {
|
||||
if Instant::now() > deadline {
|
||||
frame.destroy();
|
||||
return None;
|
||||
}
|
||||
self.queue.blocking_dispatch(&mut self.st).ok()?;
|
||||
}
|
||||
let (format, w, h, stride) = self.st.frame.buffer?;
|
||||
let WEnum::Value(fmt) = format else {
|
||||
frame.destroy();
|
||||
return None;
|
||||
};
|
||||
let size = (stride * h) as u64;
|
||||
let WEnum::Value(fmt) = format else { return None };
|
||||
let size = (stride as u64) * (h as u64);
|
||||
let file = scratch_file(size).ok()?;
|
||||
let pool = self.shm.create_pool(file.as_fd(), size as i32, &self.qh, ());
|
||||
let buffer = pool.create_buffer(0, w as i32, h as i32, stride as i32, fmt, &self.qh, ());
|
||||
|
|
@ -256,7 +281,6 @@ impl Wl {
|
|||
}
|
||||
let ok = self.st.frame.ready;
|
||||
let y_invert = self.st.frame.y_invert;
|
||||
frame.destroy();
|
||||
buffer.destroy();
|
||||
pool.destroy();
|
||||
if !ok {
|
||||
|
|
@ -295,10 +319,12 @@ pub fn to_rgba(data: &[u8], fmt: wl_shm::Format, w: u32, h: u32, stride: u32, y_
|
|||
wl_shm::Format::Xbgr8888 | wl_shm::Format::Abgr8888 => false,
|
||||
_ => return None,
|
||||
};
|
||||
let mut out = Vec::with_capacity((w * h * 4) as usize);
|
||||
// Byte maths in usize: extreme resolutions overflow a u32 product (debug builds panic).
|
||||
let mut out = Vec::with_capacity((w as usize) * (h as usize) * 4);
|
||||
for row in 0..h {
|
||||
let r = if y_invert { h - 1 - row } else { row };
|
||||
let line = data.get((r * stride) as usize..(r * stride + w * 4) as usize)?;
|
||||
let (a, b) = ((r as u64 * stride as u64) as usize, ((r as u64 * stride as u64) as usize) + (w as usize) * 4);
|
||||
let line = data.get(a..b)?;
|
||||
for px in line.chunks_exact(4) {
|
||||
if bgr {
|
||||
out.extend_from_slice(&[px[2], px[1], px[0], 255]);
|
||||
|
|
@ -425,16 +451,25 @@ pub fn idle_secs() -> Option<f64> {
|
|||
static STARTED: std::sync::Once = std::sync::Once::new();
|
||||
STARTED.call_once(|| {
|
||||
std::thread::spawn(|| {
|
||||
// Keep listening for the app's whole life: when the connection drops (compositor
|
||||
// restart, read timeout) reconnect and listen again — the old loop quit forever
|
||||
// after one break and idle_secs() went blind.
|
||||
loop {
|
||||
let Ok(conn) = Connection::connect_to_env() else { return };
|
||||
let Ok((globals, mut queue)) = registry_queue_init::<IdleState>(&conn) else { return };
|
||||
let qh = queue.handle();
|
||||
let Ok(seat) = globals.bind::<wl_seat::WlSeat, _, _>(&qh, 1..=1, ()) else { return };
|
||||
let Ok(notifier) = globals.bind::<ext_idle_notifier_v1::ExtIdleNotifierV1, _, _>(&qh, 1..=1, ()) else { return };
|
||||
notifier.get_idle_notification(1000, &seat, &qh, ());
|
||||
// Keep the notification object alive for as long as the loop runs: wayland-client
|
||||
// destroys an object when its last handle drops, and dropping this return value
|
||||
// here meant Idled events never arrived (idle_secs() was forever 0).
|
||||
let _notification = notifier.get_idle_notification(1000, &seat, &qh, ());
|
||||
*IDLE.lock().unwrap_or_else(|e| e.into_inner()) = Some(None);
|
||||
let mut st = IdleState;
|
||||
while queue.blocking_dispatch(&mut st).is_ok() {}
|
||||
*IDLE.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
});
|
||||
});
|
||||
None
|
||||
|
|
|
|||
32
src/x11.rs
32
src/x11.rs
|
|
@ -53,16 +53,16 @@ pub fn capture() -> Result<image::RgbImage, String> {
|
|||
return None;
|
||||
}
|
||||
let lsb = setup.image_byte_order == xproto::ImageOrder::LSB_FIRST;
|
||||
let (w, h) = (x.width as u32, x.height as u32);
|
||||
let mut rgb = Vec::with_capacity((w * h * 3) as usize);
|
||||
for px in reply.data.chunks_exact(4).take((w * h) as usize) {
|
||||
let (w, h) = (x.width as usize, x.height as usize);
|
||||
let mut rgb = Vec::with_capacity(w * h * 3);
|
||||
for px in reply.data.chunks_exact(4).take(w * h) {
|
||||
if lsb {
|
||||
rgb.extend_from_slice(&[px[2], px[1], px[0]]);
|
||||
} else {
|
||||
rgb.extend_from_slice(&[px[1], px[2], px[3]]);
|
||||
}
|
||||
}
|
||||
image::RgbImage::from_raw(w, h, rgb)
|
||||
image::RgbImage::from_raw(w as u32, h as u32, rgb)
|
||||
})
|
||||
.ok_or_else(|| "讀不到螢幕畫面(需要 X11 桌面;Wayland 請在登入畫面選「Xorg」工作階段)".to_string())
|
||||
}
|
||||
|
|
@ -302,7 +302,11 @@ pub fn type_text(text: &str) {
|
|||
for c in text.chars() {
|
||||
let sym = keysym_for_char(c);
|
||||
let syms = vec![sym; per];
|
||||
x.conn.change_keyboard_mapping(1, spare, per as u8, &syms).ok()?;
|
||||
// On any failure stop the loop and fall through to restoring the mapping: an
|
||||
// early `?` here left the typed character glued to a real key forever.
|
||||
if x.conn.change_keyboard_mapping(1, spare, per as u8, &syms).is_err() {
|
||||
break;
|
||||
}
|
||||
flush(x);
|
||||
std::thread::sleep(Duration::from_millis(12));
|
||||
fake(x, xproto::KEY_PRESS_EVENT, spare, 0, 0);
|
||||
|
|
@ -428,11 +432,13 @@ pub fn record(mut each: impl FnMut(Input), keep_going: impl Fn() -> bool + Send
|
|||
_ => 0,
|
||||
}
|
||||
};
|
||||
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let stopper = std::thread::spawn({
|
||||
let ctx = ctx;
|
||||
let stop = stop.clone();
|
||||
move || {
|
||||
// Disabling the context ends the data stream below.
|
||||
while keep_going() {
|
||||
while keep_going() && !stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
}
|
||||
let _ = ctrl.record_disable_context(ctx);
|
||||
|
|
@ -442,7 +448,16 @@ pub fn record(mut each: impl FnMut(Input), keep_going: impl Fn() -> bool + Send
|
|||
let _ = ctrl.flush();
|
||||
}
|
||||
});
|
||||
let stream = data.record_enable_context(ctx).map_err(|e| e.to_string())?;
|
||||
let stream = match data.record_enable_context(ctx) {
|
||||
Ok(s) => s,
|
||||
// A failed start must stop the watchdog thread too — it used to spin forever
|
||||
// (holding an X connection) on this early return.
|
||||
Err(e) => {
|
||||
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let _ = stopper.join();
|
||||
return Err(e.to_string());
|
||||
}
|
||||
};
|
||||
for reply in stream {
|
||||
let Ok(reply) = reply else { break };
|
||||
if reply.category != 0 {
|
||||
|
|
@ -482,6 +497,9 @@ pub fn record(mut each: impl FnMut(Input), keep_going: impl Fn() -> bool + Send
|
|||
}
|
||||
}
|
||||
}
|
||||
// The stream broke (the X server died, the context was dropped): tell the watchdog to
|
||||
// stop first — waiting on a thread that keeps polling forever is what froze the old code.
|
||||
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let _ = stopper.join();
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue