AnyBot/src/cloudbox.rs

1615 lines
66 KiB
Rust
Raw Normal View History

// 「Bot 的電腦」: AnyBot's own Linux computer that every bot shares, like Grok Bot's cloud "box".
//
// What the original does (Grok Bot 0.78.1, main-app.cjs dev-box control plane + local-exec-daemon):
// - The box is a container with an X11 desktop driven by xdotool (ComputerUseDriver XDOTOOL /
// NATIVE_X11), its size read with xrandr, watched through noVNC websockify on port 6080
// (`Ap.primaryNovncWebsockify:6080`).
// - Run states: ABSENT → 「建立中」, HIBERNATED → 「喚醒中」, STARTING → 「連線中」, RUNNING.
// - Health: GET /health with a 3 s timeout (QQn=3e3) that also says whether it's busy.
// - Store flush before a rebuild: POST /api/snapshotBoxStoreNow, 30 s (VQn=3e4).
// - Three rebuilds, one at a time (`isRecreateInFlight`):
// update (keep data, refused while busy): backing-up → creating → moving
// reset (wipe): cleaning-up → wiping → creating
// restore (force, from the durable store): cleaning-up → creating, then wait up to 90 s
// (zQn=9e4) polling every 2 s (HQn=2e3) for the copy-in to say "hydrated"
// - recreate-resume.json replays a rebuild cut short by a restart if it is under 10 minutes old
// ($Qn=10*6e4) and the box isn't healthy.
// Here the box is a local Docker container (or one on another Docker host, `ssh://me@server`),
// the durable store is a snapshot of the bot's home folder kept on this Mac, and xdotool runs
// through `docker exec`.
use base64::Engine;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime};
pub const CONTAINER: &str = "anybot-box";
pub const IMAGE: &str = "anybot-box:1";
/// The bot's files live here and survive an update (the original's mounted sand-data tree).
pub const VOLUME: &str = "anybot-box-home";
pub const HOME: &str = "/home/bot";
pub const DISPLAY: &str = ":1";
/// noVNC inside the box (Ap.primaryNovncWebsockify).
pub const NOVNC_PORT: u16 = 6080;
/// Where noVNC is published on this Mac (estimated; any free port works).
pub const HOST_PORT: u16 = 16080;
/// Desktop size (estimated; the original reads it with xrandr).
pub const SCREEN: &str = "1280x800x24";
/// Health probe timeout (QQn).
pub const HEALTH_TIMEOUT_MS: u64 = 3_000;
/// Snapshot ("flush the durable store") timeout (VQn).
pub const SNAPSHOT_TIMEOUT_MS: u64 = 30_000;
/// Restore copy-in: longest wait and poll interval (zQn, HQn).
pub const COPY_IN_WAIT_MS: u64 = 90_000;
pub const COPY_IN_POLL_MS: u64 = 2_000;
/// A rebuild marker older than this is cleared instead of replayed ($Qn).
pub const RESUME_FRESH_MS: u64 = 600_000;
/// Boot: longest wait for the desktop to come up (estimated).
pub const BOOT_WAIT_MS: u64 = 60_000;
/// Quiet time before the daily snapshot is taken (nobody using the box for this long).
pub const QUIET_SECS: u64 = 900;
/// The box's files are saved to this computer about once a day (it no longer sleeps, which is
/// when they used to be saved).
pub const SNAPSHOT_EVERY_SECS: i64 = 24 * 3600;
/// Secret names: an upper-case letter, then A–Z, 0–9, _ (original rule); size limits estimated.
pub const SECRET_MAX_VALUE_BYTES: usize = 32_768;
pub const SECRET_MAX_COUNT: usize = 100;
/// xdotool timing, matching the Mac helper where it applies (CUActuator).
const CLICK_REPEAT_MS: u64 = 80;
const TYPE_DELAY_MS: u64 = 12;
const DOCKERFILE: &str = include_str!("../box/Dockerfile");
const START_SH: &str = include_str!("../box/start.sh");
const FLUXBOX_INIT: &str = include_str!("../box/fluxbox-init");
const OVERLAY: &str = include_str!("../box/overlay");
const XRESOURCES: &str = include_str!("../box/Xresources");
const MENU: &str = include_str!("../box/menu");
// ---------- state ----------
#[derive(Clone, Debug, PartialEq)]
pub enum Run {
/// Docker isn't installed or isn't running.
NoDocker(String),
/// Not made yet (ABSENT).
Absent,
/// Asleep (HIBERNATED): paused or stopped.
Asleep,
/// Being made, woken or connected (the transient states).
Starting(&'static str),
Running,
/// A rebuild is under way: (phase, detail).
Rebuilding(String, String),
Failed(String),
}
impl Run {
pub fn label(&self) -> String {
match self {
Run::NoDocker(_) => "沒有 Docker".into(),
Run::Absent => "尚未建立".into(),
Run::Asleep => "休眠中".into(),
Run::Starting(s) => (*s).into(),
Run::Running => "執行中".into(),
Run::Rebuilding(p, _) => phase_label(p).into(),
Run::Failed(_) => "發生問題".into(),
}
}
pub fn is_running(&self) -> bool {
*self == Run::Running
}
}
pub fn phase_label(p: &str) -> &'static str {
match p {
"backing-up" => "正在備份",
"creating" => "正在建立",
"moving" => "正在搬移資料",
"cleaning-up" => "正在拆除舊電腦",
"wiping" => "正在清除資料",
"restoring" => "正在從快照還原",
"done" => "完成",
"failed" => "失敗",
_ => "處理中",
}
}
#[derive(Clone, Serialize, Deserialize, Default)]
pub struct Config {
/// Empty = this Mac's Docker; otherwise a Docker host such as `ssh://me@server`.
#[serde(default)]
pub docker_host: String,
/// 排程更新: local "HH:MM" to update when a newer computer is available (None = off).
#[serde(default)]
pub auto_update_at: Option<String>,
/// The day a scheduled update last ran (so it runs once).
#[serde(default)]
pub auto_update_done: String,
/// 網路流量改走你的電腦 (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).
pub const SCHEDULE_GRACE_MINUTES: i64 = 60;
struct Shared {
run: Run,
last_used: Instant,
rebuilding: bool,
log: Vec<String>,
/// Screenshot pixel → desktop pixel (for the bot's coordinates).
scale: Option<(f64, f64)>,
tunnel: Option<std::process::Child>,
host_port: u16,
}
fn shared() -> &'static Mutex<Shared> {
static S: OnceLock<Mutex<Shared>> = OnceLock::new();
S.get_or_init(|| Mutex::new(Shared { run: Run::Absent, last_used: Instant::now(), rebuilding: false, log: vec![], scale: None, tunnel: None, host_port: HOST_PORT }))
}
fn config() -> &'static Mutex<Config> {
static C: OnceLock<Mutex<Config>> = OnceLock::new();
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);
/// Marks the box as in use while alive (keeps it awake; an update waits for it).
pub struct Busy(bool);
impl Busy {
pub fn new(on: bool) -> Self {
if on {
BUSY.fetch_add(1, Ordering::SeqCst);
touch();
}
Busy(on)
}
}
impl Drop for Busy {
fn drop(&mut self) {
if self.0 {
BUSY.fetch_sub(1, Ordering::SeqCst);
touch();
}
}
}
pub fn is_busy() -> bool {
BUSY.load(Ordering::SeqCst) > 0
}
pub fn touch() {
shared().lock().unwrap().last_used = Instant::now();
}
pub fn run_state() -> Run {
shared().lock().unwrap().run.clone()
}
fn set_run(r: Run) {
shared().lock().unwrap().run = r;
}
pub fn log_lines() -> Vec<String> {
shared().lock().unwrap().log.clone()
}
fn log(line: &str) {
let mut s = shared().lock().unwrap();
s.log.push(format!("{} {line}", chrono::Local::now().format("%H:%M:%S")));
let n = s.log.len();
if n > 200 {
s.log.drain(..n - 200);
}
}
// ---------- files on this Mac ----------
fn data_dir() -> PathBuf {
crate::model::State::path().parent().map(|p| p.to_path_buf()).unwrap_or_else(std::env::temp_dir).join("box")
}
fn config_path() -> PathBuf {
data_dir().join("config.json")
}
fn load_config() -> Config {
std::fs::read_to_string(config_path()).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
}
pub fn docker_host() -> String {
config().lock().unwrap().docker_host.clone()
}
pub fn set_docker_host(h: &str) {
let h = h.trim().to_string();
let mut c = config().lock().unwrap();
if c.docker_host == h {
return;
}
c.docker_host = h;
let _ = std::fs::create_dir_all(data_dir());
let _ = std::fs::write(config_path(), serde_json::to_string_pretty(&*c).unwrap_or_default());
drop(c);
if let Some(mut t) = shared().lock().unwrap().tunnel.take() {
let _ = t.kill();
}
set_run(Run::Absent);
refresh();
}
fn save_config(c: &Config) {
let _ = std::fs::create_dir_all(data_dir());
let _ = std::fs::write(config_path(), serde_json::to_string_pretty(c).unwrap_or_default());
}
pub fn egress_via_mac() -> bool {
config().lock().unwrap().egress_via_mac
}
/// Turn routing through this Mac on or off. Only this Mac's Docker can reach it.
pub fn set_egress_via_mac(on: bool) -> Result<(), String> {
if on {
if !docker_host().is_empty() {
return Err("Bot 的電腦在別的主機上時,不能改走這台 Mac。".into());
}
crate::egress::start()?;
}
let mut c = config().lock().unwrap();
c.egress_via_mac = on;
save_config(&c);
Ok(())
}
/// Proxy settings for new commands in the box when routing through this Mac.
fn egress_env() -> String {
if !egress_via_mac() || !docker_host().is_empty() || !crate::policy::read_egress().allows() {
return String::new();
}
match crate::egress::start() {
Ok(port) => {
let url = format!("http://host.docker.internal:{port}");
["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"].iter().map(|k| format!(" -e {k}={url}")).collect::<String>() + " -e NO_PROXY=localhost,127.0.0.1 -e no_proxy=localhost,127.0.0.1"
}
Err(_) => String::new(),
}
}
pub fn auto_update_at() -> Option<String> {
config().lock().unwrap().auto_update_at.clone()
}
pub fn set_auto_update_at(at: Option<String>) {
let mut c = config().lock().unwrap();
c.auto_update_at = at;
save_config(&c);
}
/// The computer version baked into this AnyBot (the Dockerfile's label).
pub fn wanted_version() -> String {
DOCKERFILE.lines().find_map(|l| l.trim().strip_prefix("LABEL anybot.box.version=")).map(|v| v.trim_matches('"').to_string()).unwrap_or_default()
}
/// The running computer is older than the one this AnyBot carries.
pub fn update_available() -> bool {
match dk(&["inspect", "-f", "{{index .Config.Labels \"anybot.box.version\"}}", CONTAINER]) {
Ok(v) => v.trim() != wanted_version(),
Err(_) => false,
}
}
/// Is `now` inside the window [at, at + grace)? (`at` is "HH:MM").
pub fn in_schedule_window(at: &str, now: chrono::NaiveTime) -> bool {
let Ok(t) = chrono::NaiveTime::parse_from_str(at.trim(), "%H:%M") else { return false };
let mins = (now - t).num_minutes().rem_euclid(24 * 60);
mins < SCHEDULE_GRACE_MINUTES
}
fn maybe_scheduled_update() {
let (at, done) = {
let c = config().lock().unwrap();
(c.auto_update_at.clone(), c.auto_update_done.clone())
};
let Some(at) = at else { return };
let now = chrono::Local::now();
let today = now.format("%Y-%m-%d").to_string();
if done == today || !in_schedule_window(&at, now.time()) || is_busy() || !update_available() {
return;
}
log(&format!("排程更新({at})開始。"));
if rebuild(Rebuild::Update).is_ok() {
let mut c = config().lock().unwrap();
c.auto_update_done = today;
save_config(&c);
}
}
pub fn snapshot_path() -> PathBuf {
data_dir().join("snapshot.tar.gz")
}
/// When the last snapshot was saved.
pub fn snapshot_time() -> Option<chrono::DateTime<chrono::Local>> {
std::fs::metadata(snapshot_path()).ok()?.modified().ok().map(chrono::DateTime::<chrono::Local>::from)
}
fn resume_path() -> PathBuf {
data_dir().join("recreate-resume.json")
}
/// Where docker and ssh live when the app is started from Finder (no shell PATH).
pub fn tool_path() -> String {
let base = std::env::var("PATH").unwrap_or_default();
let mut parts: Vec<String> = base.split(':').filter(|s| !s.is_empty()).map(String::from).collect();
let home = dirs::home_dir().map(|h| h.display().to_string()).unwrap_or_default();
for extra in ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin", &format!("{home}/.orbstack/bin"), "/Applications/Docker.app/Contents/Resources/bin"] {
if !parts.iter().any(|p| p == extra) {
parts.push(extra.to_string());
}
}
parts.join(":")
}
fn docker() -> Command {
let mut c = Command::new("docker");
c.env("PATH", tool_path());
let host = docker_host();
if !host.is_empty() {
c.env("DOCKER_HOST", host);
}
c.stdin(Stdio::null());
c
}
/// The `docker …` prefix for shell strings (used by the command tools).
fn docker_prefix() -> String {
let host = docker_host();
if host.is_empty() {
"docker".into()
} else {
format!("DOCKER_HOST={} docker", crate::tools::sh_quote(&host))
}
}
/// Run docker; Ok(stdout) or Err(stderr).
fn dk(args: &[&str]) -> Result<String, 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> {
use std::io::Read;
let mut child = docker().args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("找不到 docker:{e}"))?;
// Drain both pipes while waiting, or a big screenshot fills the pipe and stalls docker.
let mut so = child.stdout.take().ok_or("沒有輸出")?;
let mut se = child.stderr.take().ok_or("沒有輸出")?;
let out_t = std::thread::spawn(move || {
let mut v = vec![];
let _ = so.read_to_end(&mut v);
v
});
let err_t = std::thread::spawn(move || {
let mut v = vec![];
let _ = se.read_to_end(&mut v);
v
});
let start = Instant::now();
let status = loop {
if let Ok(Some(st)) = child.try_wait() {
break st;
}
if start.elapsed() > Duration::from_millis(ms) {
let _ = child.kill();
let _ = child.wait();
return Err("逾時".into());
}
std::thread::sleep(Duration::from_millis(10));
};
let out = out_t.join().unwrap_or_default();
let err = err_t.join().unwrap_or_default();
if status.success() {
Ok(out)
} else {
Err(String::from_utf8_lossy(&err).trim().to_string())
}
}
// ---------- status ----------
/// Ask Docker what state the box is in (doesn't change anything).
pub fn probe() -> Run {
if let Err(e) = dk(&["version", "--format", "{{.Server.Version}}"]) {
return Run::NoDocker(if e.contains("找不到") && !cfg!(target_os = "macos") {
"這台電腦沒有安裝 Docker。安裝 Docker Engine(Ubuntu / Debian:sudo apt install docker.io),再把自己加進 docker 群組(sudo usermod -aG docker $USER,然後重新登入),Bot 就會有自己的完整電腦。".into()
} else if e.contains("找不到") {
"這台 Mac 沒有安裝 Docker。安裝 OrbStack 或 Docker Desktop 後,Bot 就會有自己的完整電腦。".into()
} else if e.contains("permission denied") && !cfg!(target_os = "macos") {
"Docker 不讓 AnyBot 使用:請執行 sudo usermod -aG docker $USER,然後登出再登入。".into()
} else {
format!("Docker 沒有在執行:{}", e.lines().last().unwrap_or(""))
});
}
match dk(&["inspect", "-f", "{{.State.Status}}", CONTAINER]) {
Err(_) => Run::Absent,
Ok(s) => match s.as_str() {
"running" => {
// The health probe (3 s, like the original's GET /health).
if dk_timeout(&["exec", CONTAINER, "test", "-f", "/tmp/anybot-box.ready"], HEALTH_TIMEOUT_MS).is_ok() {
Run::Running
} else {
Run::Starting("連線中")
}
}
"paused" | "exited" | "created" => Run::Asleep,
"restarting" => Run::Starting("連線中"),
other => Run::Failed(format!("容器狀態:{other}")),
},
}
}
/// Refresh the shown state in the background (unless a rebuild or start owns it).
pub fn refresh() {
std::thread::spawn(|| {
let r = probe();
let mut s = shared().lock().unwrap();
if s.rebuilding || matches!(s.run, Run::Starting(_)) && !matches!(r, Run::Running) {
return;
}
s.run = r;
});
}
pub fn available() -> bool {
!matches!(run_state(), Run::NoDocker(_))
}
// ---------- image ----------
fn image_exists() -> bool {
dk(&["image", "inspect", IMAGE]).is_ok()
}
/// Build the box image from the files baked into AnyBot.
fn build_image(pull: bool) -> Result<(), String> {
let dir = std::env::temp_dir().join(format!("anybot-box-build-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
for (name, body) in [("Dockerfile", DOCKERFILE), ("start.sh", START_SH), ("fluxbox-init", FLUXBOX_INIT), ("overlay", OVERLAY), ("Xresources", XRESOURCES), ("menu", MENU)] {
std::fs::write(dir.join(name), body).map_err(|e| e.to_string())?;
}
log("正在建立電腦的映像檔(第一次要幾分鐘)…");
let mut args = vec!["build", "-q", "-t", IMAGE];
if pull {
args.push("--pull");
}
let d = dir.display().to_string();
args.push(&d);
// 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)))
}
fn tail(s: &str, n: usize) -> String {
let c: Vec<char> = s.chars().collect();
if c.len() <= n {
s.to_string()
} else {
c[c.len() - n..].iter().collect()
}
}
// ---------- lifecycle ----------
fn free_port(start: u16) -> u16 {
if !docker_host().is_empty() {
return start;
}
(start..start + 50).find(|p| std::net::TcpListener::bind(("127.0.0.1", *p)).is_ok()).unwrap_or(start)
}
fn create_container() -> Result<(), String> {
if !image_exists() {
build_image(false)?;
}
let port = free_port(HOST_PORT);
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", "--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(())
}
fn wait_ready(ms: u64) -> Result<(), String> {
let start = Instant::now();
while start.elapsed() < Duration::from_millis(ms) {
if dk(&["exec", CONTAINER, "test", "-f", "/tmp/anybot-box.ready"]).is_ok() {
return Ok(());
}
std::thread::sleep(Duration::from_millis(250));
}
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("這台電腦的瀏覽器沒有及時打開,請再試一次。".into())
}
static START_LOCK: Mutex<()> = Mutex::new(());
/// Make sure the box is up, waking or creating it as needed. Blocks until it's ready.
pub fn ensure_running() -> Result<(), String> {
let _g = START_LOCK.lock().unwrap();
if shared().lock().unwrap().rebuilding {
return Err("Bot 的電腦正在更新,請等它完成再試。".into());
}
touch();
let r = probe();
let result = match r {
Run::Running => Ok(()),
Run::NoDocker(e) => Err(e),
Run::Absent => {
set_run(Run::Starting("建立中"));
create_container().and_then(|_| wait_ready(BOOT_WAIT_MS))
}
Run::Asleep => {
set_run(Run::Starting("喚醒中"));
log("喚醒電腦…");
let st = dk(&["inspect", "-f", "{{.State.Status}}", CONTAINER]).unwrap_or_default();
let woke = if st == "paused" { dk(&["unpause", CONTAINER]) } else { dk(&["start", CONTAINER]) };
woke.map_err(|e| format!("叫不醒電腦:{e}")).and_then(|_| wait_ready(BOOT_WAIT_MS))
}
Run::Starting(_) => {
set_run(Run::Starting("連線中"));
wait_ready(BOOT_WAIT_MS)
}
Run::Rebuilding(..) => Err("Bot 的電腦正在更新,請等它完成再試。".into()),
Run::Failed(e) => {
// A broken container: start it again.
log(&format!("電腦狀態異常({e}),重新啟動…"));
set_run(Run::Starting("連線中"));
dk(&["restart", CONTAINER]).map_err(|e| e.to_string()).and_then(|_| wait_ready(BOOT_WAIT_MS))
}
};
match &result {
Ok(()) => {
set_run(Run::Running);
let _ = dk(&["exec", CONTAINER, "sh", "-c", &format!("mkdir -p {HOME} && chown bot:bot {HOME}")]);
}
Err(e) => {
log(e);
set_run(match probe() {
Run::NoDocker(x) => Run::NoDocker(x),
_ => Run::Failed(e.clone()),
});
}
}
touch();
result
}
/// Started once: keeps the box awake (wakes it when it was asleep), saves its files once a day,
/// and replays a rebuild cut short by a restart. The person asked for the bots' computer to be
/// always there: an idle box used to be paused after 15 minutes, and the person — and a bot
/// working from a brain far away — kept finding it "asleep".
pub fn start_background() {
static STARTED: AtomicBool = AtomicBool::new(false);
if STARTED.swap(true, Ordering::SeqCst) {
return;
}
std::thread::spawn(|| {
set_run(probe());
resume_interrupted();
loop {
// (First wait a little: a computer showing another brain says so right after start.)
std::thread::sleep(Duration::from_secs(20));
let (idle, rebuilding) = {
let s = shared().lock().unwrap();
(s.last_used.elapsed().as_secs(), s.rebuilding)
};
if !rebuilding {
if IS_BRAIN.load(Ordering::SeqCst) && matches!(probe(), Run::Asleep) {
log("Bot 的電腦不休眠:叫醒它。");
let _ = ensure_running();
}
let quiet = idle >= QUIET_SECS && !is_busy() && !HELD_BY_PERSON.load(Ordering::SeqCst) && !viewer_open();
let due = snapshot_time().map(|t| (chrono::Local::now() - t).num_seconds() >= SNAPSHOT_EVERY_SECS).unwrap_or(true);
if quiet && due && run_state().is_running() {
let _ = save_snapshot();
}
refresh();
maybe_scheduled_update();
}
}
});
}
/// This AnyBot is the brain (its box is the bots' computer). A computer showing another brain
/// doesn't use its own box, so it isn't kept awake there.
static IS_BRAIN: AtomicBool = AtomicBool::new(true);
pub fn set_brain(on: bool) {
IS_BRAIN.store(on, Ordering::SeqCst);
}
static VIEWER_OPEN: AtomicBool = AtomicBool::new(false);
pub fn set_viewer_open(on: bool) {
VIEWER_OPEN.store(on, Ordering::SeqCst);
if on {
touch();
}
}
fn viewer_open() -> bool {
VIEWER_OPEN.load(Ordering::SeqCst)
}
// ---------- snapshots (the durable store) ----------
/// Save the bot's home folder to this Mac. Returns its size in bytes.
pub fn save_snapshot() -> Result<u64, String> {
let _ = std::fs::create_dir_all(data_dir());
let tmp = data_dir().join("snapshot.tmp");
let file = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
let mut child = docker()
.args(["exec", CONTAINER, "tar", "-C", HOME, "--exclude=./.cache", "-czf", "-", "."])
.stdout(Stdio::from(file))
.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() {
break st.success();
}
if start.elapsed() > Duration::from_millis(SNAPSHOT_TIMEOUT_MS * 4) {
let _ = child.kill();
break false;
}
std::thread::sleep(Duration::from_millis(50));
};
let _ = err_t.join();
if !ok {
let _ = std::fs::remove_file(&tmp);
return Err("快照沒有存成功".into());
}
std::fs::rename(&tmp, snapshot_path()).map_err(|e| e.to_string())?;
let n = std::fs::metadata(snapshot_path()).map(|m| m.len()).unwrap_or(0);
log(&format!("已儲存快照({})。", human(n)));
Ok(n)
}
fn copy_in_snapshot() -> Result<(), String> {
use std::io::Write;
let data = std::fs::read(snapshot_path()).map_err(|_| "還沒有快照可以還原".to_string())?;
let mut child = docker()
.args(["exec", "-i", CONTAINER, "sh", "-c", &format!("tar -C {HOME} -xzf - && chown -R bot:bot {HOME} && echo '[box-copy-in] result outcome=hydrated' >> /tmp/anybot-copy-in.log")])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| e.to_string())?;
child.stdin.take().ok_or("寫不進電腦")?.write_all(&data).map_err(|e| e.to_string())?;
let out = child.wait_with_output().map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(format!("還原失敗:{}", String::from_utf8_lossy(&out.stderr).trim()));
}
// Like readCopyInOutcome: wait for the copy-in to report "hydrated".
let start = Instant::now();
while start.elapsed() < Duration::from_millis(COPY_IN_WAIT_MS) {
if let Ok(s) = dk(&["exec", CONTAINER, "sh", "-c", "cat /tmp/anybot-copy-in.log 2>/dev/null || true"]) {
if s.contains("outcome=hydrated") {
return Ok(());
}
}
std::thread::sleep(Duration::from_millis(COPY_IN_POLL_MS));
}
Err("還原沒有完成:copy-in outcome=unknown".into())
}
fn home_has_files() -> bool {
dk(&["exec", CONTAINER, "sh", "-c", &format!("ls -A {HOME} | head -1")]).map(|s| !s.trim().is_empty()).unwrap_or(false)
}
pub fn human(b: u64) -> String {
match b {
b if b >= 1 << 30 => format!("{:.1} GB", b as f64 / (1u64 << 30) as f64),
b if b >= 1 << 20 => format!("{:.1} MB", b as f64 / (1u64 << 20) as f64),
b if b >= 1 << 10 => format!("{:.0} KB", b as f64 / 1024.0),
b => format!("{b} B"),
}
}
// ---------- rebuilds ----------
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Rebuild {
/// 重建電腦: keep files and logins, remove installed apps and packages.
Update,
/// 重設: a brand-new computer, nothing kept.
Reset,
/// 復原: start again from the last saved snapshot.
Restore,
}
impl Rebuild {
pub fn title(self) -> &'static str {
match self {
Rebuild::Update => "重建電腦",
Rebuild::Reset => "重設電腦",
Rebuild::Restore => "從快照復原",
}
}
/// The original's confirmation wording.
pub fn warning(self) -> &'static str {
match self {
Rebuild::Update => "你的檔案和登入資訊會保留,但已安裝的應用程式和套件會被移除。所有 Bot 會一起更新。正在工作的 Bot 會先停下來,聊天會保留。",
Rebuild::Reset => "重新建立電腦並重新連線。電腦裡的所有檔案、登入資訊和已安裝的應用程式都會被清除(你的 Bot 和聊天不受影響)。",
Rebuild::Restore => "從你上次儲存的快照重新啟動它。最近的變更可能會遺失,已安裝的 App 會被移除。你的 Bot、檔案和登入資訊都是安全的。",
}
}
}
#[derive(Serialize, Deserialize)]
struct ResumeMarker {
#[serde(rename = "resumeKind")]
kind: Rebuild,
#[serde(rename = "writtenAtMs")]
written_at_ms: u64,
}
fn now_ms() -> u64 {
SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
}
/// Start a rebuild in the background. Err = refused (already running, busy, no snapshot…).
pub fn rebuild(kind: Rebuild) -> Result<(), String> {
{
let mut s = shared().lock().unwrap();
if s.rebuilding {
return Err("已經有一個更新在進行中。".into());
}
if kind == Rebuild::Update && BUSY.load(Ordering::SeqCst) > 0 {
return Err("有 Bot 正在用這台電腦,等它做完再重建(或先停止它)。".into());
}
if kind == Rebuild::Restore && !snapshot_path().exists() {
return Err("還沒有儲存過快照,沒有東西可以還原。".into());
}
s.rebuilding = true;
}
std::thread::spawn(move || {
let _ = std::fs::create_dir_all(data_dir());
let _ = std::fs::write(resume_path(), serde_json::to_string(&ResumeMarker { kind, written_at_ms: now_ms() }).unwrap_or_default());
let phase = |p: &str, d: &str| {
log(&format!("{}:{d}", phase_label(p)));
shared().lock().unwrap().run = Run::Rebuilding(p.into(), d.into());
};
let r = run_rebuild(kind, &phase);
let _ = std::fs::remove_file(resume_path());
match r {
Ok(()) => {
log(&format!("{}完成。", kind.title()));
let mut s = shared().lock().unwrap();
s.rebuilding = false;
s.run = Run::Running;
s.last_used = Instant::now();
}
Err(e) => {
log(&format!("{}失敗:{e}", kind.title()));
let mut s = shared().lock().unwrap();
s.rebuilding = false;
s.run = Run::Failed(e);
}
}
});
Ok(())
}
fn run_rebuild(kind: Rebuild, phase: &dyn Fn(&str, &str)) -> Result<(), String> {
match kind {
Rebuild::Update => {
let had = matches!(probe(), Run::Running);
phase("backing-up", "儲存一份快照");
if had {
if let Err(e) = save_snapshot() {
log(&format!("快照沒有存成功({e}),改以掛載的資料為準。"));
}
}
let had_files = had && home_has_files();
phase("creating", "更新電腦的映像檔");
build_image(true)?;
phase("creating", "換上一台新的電腦");
let _ = dk(&["rm", "-f", CONTAINER]);
create_container()?;
wait_ready(BOOT_WAIT_MS)?;
phase("moving", "確認保留的檔案和新電腦的連線");
if had_files && !home_has_files() {
return Err("重建後找不到原本的檔案".into());
}
Ok(())
}
Rebuild::Reset => {
phase("cleaning-up", "拆掉舊的電腦");
let _ = dk(&["rm", "-f", CONTAINER]);
phase("wiping", "清除電腦裡的資料");
let _ = dk(&["volume", "rm", "-f", VOLUME]);
phase("creating", "開一台全新的電腦");
if !image_exists() {
build_image(false)?;
}
create_container()?;
wait_ready(BOOT_WAIT_MS)
}
Rebuild::Restore => {
phase("cleaning-up", "拆掉舊的電腦和它的資料");
let _ = dk(&["rm", "-f", CONTAINER]);
let _ = dk(&["volume", "rm", "-f", VOLUME]);
phase("creating", "從快照開一台新的電腦");
if !image_exists() {
build_image(false)?;
}
create_container()?;
wait_ready(BOOT_WAIT_MS)?;
phase("restoring", "把快照裡的檔案放回去");
copy_in_snapshot()
}
}
}
/// resumeInterruptedRecreate: replay a fresh marker when the box isn't healthy.
fn resume_interrupted() {
let Some(m) = std::fs::read_to_string(resume_path()).ok().and_then(|s| serde_json::from_str::<ResumeMarker>(&s).ok()) else { return };
if now_ms().saturating_sub(m.written_at_ms) >= RESUME_FRESH_MS {
log("清掉一個太舊的未完成更新紀錄(超過十分鐘)。");
let _ = std::fs::remove_file(resume_path());
return;
}
if matches!(probe(), Run::Running) {
log("上次的更新被中斷,但電腦目前正常,所以沒有重做。需要的話請再執行一次。");
let _ = std::fs::remove_file(resume_path());
return;
}
log(&format!("接續上次被中斷的「{}」。", m.kind.title()));
let _ = rebuild(m.kind);
}
// ---------- disk ----------
/// Disk use in the box, for the settings page and the clean-up request.
pub fn disk_report() -> Result<String, String> {
ensure_running()?;
dk(&["exec", "-u", "bot", CONTAINER, "sh", "-c", &format!("df -h {HOME} | tail -1; echo; du -sh {HOME}/* {HOME}/.cache 2>/dev/null | sort -rh | head -8")])
}
// ---------- secrets (write-only) ----------
fn secrets_path() -> PathBuf {
data_dir().join("secrets.json")
}
fn env_file_path() -> PathBuf {
data_dir().join("secrets.env")
}
fn read_secrets() -> std::collections::BTreeMap<String, String> {
std::fs::read_to_string(secrets_path()).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
}
fn write_secrets(m: &std::collections::BTreeMap<String, String>) -> Result<(), String> {
let _ = std::fs::create_dir_all(data_dir());
let write = |p: PathBuf, body: String| -> Result<(), String> {
std::fs::write(&p, body).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600));
}
Ok(())
};
write(secrets_path(), serde_json::to_string(m).unwrap_or_default())?;
// docker --env-file: one NAME=value per line (no newlines allowed in values).
write(env_file_path(), m.iter().map(|(k, v)| format!("{k}={}\n", v.replace('\n', "\\n"))).collect())
}
/// Names only: values can be written, never read back.
pub fn secret_names() -> Vec<String> {
read_secrets().into_keys().collect()
}
pub fn valid_secret_name(n: &str) -> bool {
let mut c = n.chars();
matches!(c.next(), Some('A'..='Z')) && c.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
}
pub fn set_secret(name: &str, value: &str) -> Result<(), String> {
let name = name.trim();
if !valid_secret_name(name) {
return Err("名稱要大寫字母開頭,只能用 A–Z、0–9 和 _,例如 CRM_API_TOKEN".into());
}
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));
}
let mut m = read_secrets();
if !m.contains_key(name) && m.len() >= SECRET_MAX_COUNT {
return Err(format!("最多 {SECRET_MAX_COUNT} 個密鑰"));
}
m.insert(name.to_string(), value.to_string());
write_secrets(&m)
}
pub fn remove_secret(name: &str) {
let mut m = read_secrets();
if m.remove(name).is_some() {
let _ = write_secrets(&m);
}
}
// ---------- commands ----------
/// The bot's own folder inside the box.
pub fn bot_dir(bot_id: u64) -> String {
format!("{HOME}/bot-{bot_id}")
}
/// 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, job: &str) -> String {
use crate::tools::sh_quote;
// 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}/{safe}");
dk(&["cp", local, &format!("{CONTAINER}:{dst}")])?;
let _ = dk(&["exec", CONTAINER, "chown", "bot:bot", &dst]);
Ok(dst)
}
/// Copy a file out of the box to this Mac (for showing the person). Returns the local path.
pub fn copy_out(path_in_box: &str) -> Result<PathBuf, String> {
ensure_running()?;
let name = std::path::Path::new(path_in_box).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "file".into());
let dir = std::env::temp_dir().join(format!("anybot-from-box-{}", chrono::Utc::now().timestamp_millis()));
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let local = dir.join(&name);
dk(&["cp", &format!("{CONTAINER}:{path_in_box}"), &local.display().to_string()]).map_err(|e| format!("拿不到檔案:{e}"))?;
Ok(local)
}
// ---------- the box's screen (xdotool) ----------
fn xdo(args: &[&str]) -> Result<String, String> {
let mut a = vec!["exec", "-u", "bot", "-e", "DISPLAY=:1", CONTAINER, "xdotool"];
a.extend_from_slice(args);
dk(&a).map_err(|e| format!("xdotool:{e}"))
}
/// The desktop as an image (full size).
pub fn grab() -> Result<image::RgbImage, String> {
let png = dk_timeout(&["exec", CONTAINER, "import", "-window", "root", "-display", DISPLAY, "png:-"], 10_000)?;
image::load_from_memory(&png).map(|i| i.to_rgb8()).map_err(|e| format!("截圖讀不出來:{e}"))
}
/// The latest picture of the box's screen, small (for the 電腦 card). While the box runs it is
/// taken now (and kept on disk); while it sleeps the last one taken is shown.
pub fn thumbnail(width: u32) -> Option<image::RgbaImage> {
let path = data_dir().join("last-screen.png");
if let Some(img) = frame() {
let h = (img.height() as f32 * width as f32 / img.width().max(1) as f32).round() as u32;
let small = image::imageops::resize(&img, width, h.max(1), image::imageops::FilterType::Triangle);
let _ = std::fs::create_dir_all(data_dir());
let _ = small.save(&path);
return Some(small);
}
image::open(&path).ok().map(|i| i.to_rgba8())
}
/// A frame for the viewer (no waking: returns None when the box isn't running).
pub fn frame() -> Option<image::RgbaImage> {
if !run_state().is_running() {
return None;
}
let png = dk_timeout(&["exec", CONTAINER, "import", "-window", "root", "-display", DISPLAY, "-quality", "1", "png:-"], 5_000).ok()?;
image::load_from_memory(&png).ok().map(|i| i.to_rgba8())
}
fn key_to_x(name: &str) -> Option<String> {
let n = name.trim().to_lowercase();
let s = match n.as_str() {
"return" | "enter" => "Return",
"tab" => "Tab",
"space" => "space",
"delete" | "backspace" => "BackSpace",
"forwarddelete" | "del" => "Delete",
"escape" | "esc" => "Escape",
"home" => "Home",
"end" => "End",
"pageup" => "Prior",
"pagedown" => "Next",
"up" | "arrowup" => "Up",
"down" | "arrowdown" => "Down",
"left" | "arrowleft" => "Left",
"right" | "arrowright" => "Right",
"-" | "minus" => "minus",
"=" | "equal" => "equal",
"[" => "bracketleft",
"]" => "bracketright",
";" => "semicolon",
"'" => "apostrophe",
"," | "comma" => "comma",
"." | "period" => "period",
"/" | "slash" => "slash",
"\\" | "backslash" => "backslash",
"`" | "grave" => "grave",
f if f.starts_with('f') && f[1..].parse::<u8>().map(|x| (1..=12).contains(&x)).unwrap_or(false) => return Some(f.to_uppercase()),
c if c.chars().count() == 1 && c.chars().all(|ch| ch.is_ascii_alphanumeric()) => return Some(c.to_string()),
_ => return None,
};
Some(s.into())
}
fn modifier_to_x(m: &str) -> Option<&'static str> {
// On Linux the shortcuts people mean by cmd (copy, paste, select all) use ctrl.
match m.trim().to_lowercase().as_str() {
"cmd" | "command" | "meta" | "control" | "ctrl" => Some("ctrl"),
"shift" => Some("shift"),
"option" | "alt" | "opt" => Some("alt"),
"super" | "win" | "windows" => Some("super"),
_ => None,
}
}
/// "cmd+shift+t" → "ctrl+shift+t"
pub fn combo_to_x(combo: &str) -> Result<String, String> {
let parts: Vec<&str> = combo.split('+').map(str::trim).filter(|p| !p.is_empty()).collect();
let Some((key, mods)) = parts.split_last() else { return Err("請給要按的鍵".into()) };
let mut out: Vec<String> = vec![];
for m in mods {
out.push(modifier_to_x(m).ok_or_else(|| format!("不認得「{m}」這個修飾鍵"))?.to_string());
}
out.push(key_to_x(key).ok_or_else(|| format!("不認得「{key}」這個鍵"))?);
Ok(out.join("+"))
}
fn mods_of(v: &Value) -> Vec<String> {
v["modifier_keys"].as_array().map(|a| a.iter().filter_map(|m| m.as_str().and_then(modifier_to_x)).map(String::from).collect()).unwrap_or_default()
}
fn button_no(b: &str) -> &'static str {
match b {
"right" => "3",
"middle" => "2",
_ => "1",
}
}
/// Run a computer_use tool on the box. Returns (text, screenshot as base64 JPEG).
pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option<String>), String> {
ensure_running()?;
let _busy = Busy::new(true);
if HELD_BY_PERSON.load(Ordering::SeqCst) && name != "computer_use_screenshot" && name != "computer_use_check_permissions" {
return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作,或先截圖看看畫面。".into());
}
let scale = || shared().lock().unwrap().scale.ok_or_else(|| "請先用 computer_use_screenshot 看一下畫面,座標要用截圖上的位置。".to_string());
let pt = |v: &Value, s: (f64, f64)| -> Result<(String, String), String> {
let (Some(x), Some(y)) = (v["x"].as_f64(), v["y"].as_f64()) else { return Err("請給 x 和 y".into()) };
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 {
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 pressed.iter().rev() {
let _ = xdo(&["keyup", m]);
}
r
};
match name {
"computer_use_check_permissions" => Ok(("這台電腦不需要另外的權限:可以截圖,也可以操作滑鼠和鍵盤。".into(), None)),
"computer_use_screenshot" => {
let img = grab()?;
let (w, h) = img.dimensions();
let (fw, fh, resize) = crate::computer::fit(w, h);
let img = if resize { image::imageops::resize(&img, fw, fh, image::imageops::FilterType::Triangle) } else { img };
let jpeg = crate::computer::encode_jpeg(&img).ok_or("截圖太大了,沒辦法送出")?;
shared().lock().unwrap().scale = Some((w as f64 / fw.max(1) as f64, h as f64 / fh.max(1) as f64));
Ok((format!("螢幕截圖({fw}×{fh},Linux 桌面)。之後的座標請用這張圖上的像素位置。"), Some(base64::engine::general_purpose::STANDARD.encode(jpeg))))
}
"computer_use_click" => {
let (x, y) = pt(v, scale()?)?;
let n = v["count"].as_u64().unwrap_or(1).clamp(1, 3).to_string();
let b = button_no(v["button"].as_str().unwrap_or("left"));
let delay = CLICK_REPEAT_MS.to_string();
with_mods(&mods_of(v), &|| xdo(&["mousemove", "--sync", &x, &y, "click", "--repeat", &n, "--delay", &delay, b]))?;
Ok((format!("已{}。要確認結果可以再截圖。", crate::computer::describe(name, &v.to_string())), None))
}
"computer_use_mouse_move" => {
let (x, y) = pt(v, scale()?)?;
xdo(&["mousemove", "--sync", &x, &y])?;
Ok(("已移動滑鼠。".into(), None))
}
"computer_use_mouse_button" => {
let b = button_no(v["button"].as_str().unwrap_or("left"));
let action = v["action"].as_str().unwrap_or("press");
if !v["x"].is_null() {
let (x, y) = pt(v, scale()?)?;
xdo(&["mousemove", "--sync", &x, &y])?;
}
xdo(&[if action == "release" { "mouseup" } else { "mousedown" }, b])?;
Ok((if action == "release" { "已放開滑鼠鍵。" } else { "已按住滑鼠鍵。" }.into(), None))
}
"computer_use_scroll" => {
let amount = crate::computer::scroll_amount(v["amount"].as_i64());
let dir = v["direction"].as_str().unwrap_or("").trim().to_lowercase();
let b = match dir.as_str() {
"up" => "4",
"down" => "5",
"left" => "6",
"right" => "7",
_ => return Err(format!("方向「{dir}」要是 up、down、left 或 right")),
};
if !v["x"].is_null() {
let (x, y) = pt(v, scale()?)?;
xdo(&["mousemove", "--sync", &x, &y])?;
}
let n = amount.to_string();
with_mods(&mods_of(v), &|| xdo(&["click", "--repeat", &n, "--delay", "20", b]))?;
Ok((format!("已捲動 {dir} ×{amount}。"), None))
}
"computer_use_drag" => {
let s = scale()?;
// 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());
}
let b = button_no(v["button"].as_str().unwrap_or("left"));
with_mods(&mods_of(v), &|| {
xdo(&["mousemove", "--sync", &path[0].0, &path[0].1])?;
xdo(&["mousedown", b])?;
std::thread::sleep(Duration::from_millis(40));
for (x, y) in &path[1..] {
xdo(&["mousemove", "--sync", x, y])?;
std::thread::sleep(Duration::from_millis(8));
}
xdo(&["mouseup", b])
})?;
Ok((format!("已拖曳({} 個點)。", path.len()), None))
}
"computer_use_typing" => {
let text = v["value"].as_str().unwrap_or("");
if text.is_empty() {
return Err("請給要打的字".into());
}
type_text(text)?;
Ok((format!("已打字({} 個字)。", text.chars().count()), None))
}
"computer_use_press_key" => {
let keys = v["key"].as_str().unwrap_or("");
let combos: Vec<String> = keys.split_whitespace().map(combo_to_x).collect::<Result<_, _>>()?;
if combos.is_empty() {
return Err("請給要按的鍵".into());
}
let hold = v["hold_duration_ms"].as_u64().unwrap_or(0).min(5_000);
for c in &combos {
if hold > 0 {
xdo(&["keydown", c])?;
std::thread::sleep(Duration::from_millis(hold));
xdo(&["keyup", c])?;
} else {
xdo(&["key", "--clearmodifiers", c])?;
}
std::thread::sleep(Duration::from_millis(20));
}
Ok((format!("已按 {keys}。"), None))
}
other => Err(format!("沒有「{other}」這個工具")),
}
}
/// Type text. Plain ASCII goes key by key; anything else (Chinese, emoji) is pasted, because
/// X keyboards can't type characters they have no key for in every program.
pub fn type_text(text: &str) -> Result<(), String> {
if text.is_ascii() {
let d = TYPE_DELAY_MS.to_string();
xdo(&["type", "--clearmodifiers", "--delay", &d, "--", text])?;
return Ok(());
}
use std::io::Write;
// Both selections hold the text; xclip keeps serving it after docker exec returns (setsid).
let mut child = docker()
.args(["exec", "-i", "-u", "bot", "-e", "DISPLAY=:1", CONTAINER, "sh", "-c", "cat > /tmp/anybot-paste.txt && for s in clipboard primary; do setsid xclip -selection $s -i /tmp/anybot-paste.txt </dev/null >/dev/null 2>&1 & done; sleep 0.15"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| e.to_string())?;
child.stdin.take().ok_or("寫不進剪貼簿")?.write_all(text.as_bytes()).map_err(|e| e.to_string())?;
let _ = child.wait();
let class = dk(&["exec", "-u", "bot", "-e", "DISPLAY=:1", CONTAINER, "sh", "-c", "xprop -id $(xdotool getactivewindow) WM_CLASS 2>/dev/null"]).unwrap_or_default().to_lowercase();
let paste = if class.contains("term") { "shift+Insert" } else { "ctrl+v" };
xdo(&["key", "--clearmodifiers", paste])?;
Ok(())
}
// ---------- the viewer's own input (when the person takes control) ----------
pub fn viewer_click(x: i64, y: i64, button: &str, count: u32) {
let (x, y, n, b) = (x.to_string(), y.to_string(), count.clamp(1, 3).to_string(), button_no(button));
let _ = xdo(&["mousemove", &x, &y, "click", "--repeat", &n, "--delay", "80", b]);
touch();
}
pub fn viewer_move(x: i64, y: i64) {
let _ = xdo(&["mousemove", &x.to_string(), &y.to_string()]);
}
pub fn viewer_scroll(x: i64, y: i64, down: bool, steps: u32) {
let _ = xdo(&["mousemove", &x.to_string(), &y.to_string(), "click", "--repeat", &steps.clamp(1, 10).to_string(), if down { "5" } else { "4" }]);
}
pub fn viewer_key(combo: &str) {
let _ = xdo(&["key", "--clearmodifiers", combo]);
touch();
}
pub fn viewer_text(text: &str) {
let _ = type_text(text);
touch();
}
// ---------- the browser viewer (noVNC) ----------
fn published_port() -> u16 {
dk(&["port", CONTAINER, &NOVNC_PORT.to_string()])
.ok()
.and_then(|s| s.lines().next().and_then(|l| l.rsplit(':').next()).and_then(|p| p.trim().parse().ok()))
.unwrap_or(HOST_PORT)
}
/// The noVNC address on this Mac (through an SSH tunnel when the box is on another host).
pub fn viewer_url(control: bool) -> Result<String, String> {
ensure_running()?;
let port = published_port();
let host = docker_host();
let local_port = if let Some(target) = host.strip_prefix("ssh://") {
let mut s = shared().lock().unwrap();
let alive = s.tunnel.as_mut().map(|c| matches!(c.try_wait(), Ok(None))).unwrap_or(false);
if !alive {
let lp = free_port(HOST_PORT + 100);
let child = Command::new("ssh")
.env("PATH", tool_path())
.args(["-N", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes", "-L", &format!("{lp}:127.0.0.1:{port}"), target])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("開不了 SSH 通道:{e}"))?;
s.tunnel = Some(child);
s.host_port = lp;
drop(s);
std::thread::sleep(Duration::from_millis(800));
lp
} else {
s.host_port
}
} else {
port
};
// 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() {
if let Some(mut t) = shared().lock().unwrap().tunnel.take() {
let _ = t.kill();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secret_names_follow_the_original_rule() {
assert!(valid_secret_name("CRM_API_TOKEN"));
assert!(valid_secret_name("A1"));
assert!(!valid_secret_name("crm_token"));
assert!(!valid_secret_name("1ABC"));
assert!(!valid_secret_name("_ABC"));
assert!(!valid_secret_name("AB-C"));
}
#[test]
fn shortcuts_become_x_keys() {
assert_eq!(combo_to_x("cmd+c").unwrap(), "ctrl+c");
assert_eq!(combo_to_x("cmd+shift+t").unwrap(), "ctrl+shift+t");
assert_eq!(combo_to_x("return").unwrap(), "Return");
assert_eq!(combo_to_x("option+f4").unwrap(), "alt+F4");
assert!(combo_to_x("hyper+x").is_err());
}
/// Uses the real Docker: `cargo test --release -- --ignored box_end_to_end --nocapture`.
#[test]
#[ignore]
fn box_end_to_end() {
use crate::tools::{run_on, Place, RunIds, Target};
let cancel = AtomicBool::new(false);
let ids = RunIds::default();
ensure_running().expect("box starts");
assert!(run_state().is_running());
let t = Target { name: "Bot 的電腦".into(), place: Place::Box { dir: bot_dir(999) } };
let (out, ok) = run_on("shell", r#"{"command":"pwd && whoami && echo $DISPLAY"}"#, &t, &cancel, &ids);
println!("shell: {out}");
assert!(ok && out.contains("/home/bot/bot-999") && out.contains("bot") && out.contains(":1"));
let (out, ok) = run_on("edit_file", r#"{"path":"notes/hello.txt","old_text":"","new_text":"第一行\nhello world\n"}"#, &t, &cancel, &ids);
assert!(ok, "{out}");
let (out, ok) = run_on("edit_file", r#"{"path":"notes/hello.txt","old_text":"hello world","new_text":"你好 世界"}"#, &t, &cancel, &ids);
assert!(ok, "{out}");
let (out, ok) = run_on("read_file", r#"{"path":"notes/hello.txt"}"#, &t, &cancel, &ids);
println!("read: {out}");
assert!(ok && out.contains("你好 世界") && !out.contains("hello world"));
let (out, ok) = run_on("search", r#"{"pattern":"世界","path":"."}"#, &t, &cancel, &ids);
assert!(ok && out.contains("hello.txt"), "{out}");
// secrets reach commands, never shown by the app
set_secret("ANYBOT_TEST_TOKEN", "s3cret-value").unwrap();
let (out, ok) = run_on("shell", r#"{"command":"test \"$ANYBOT_TEST_TOKEN\" = s3cret-value && echo has-secret"}"#, &t, &cancel, &ids);
remove_secret("ANYBOT_TEST_TOKEN");
assert!(ok && out.contains("has-secret"), "{out}");
// background command takes input
let (out, ok) = run_on("shell", r#"{"command":"read -p 'name? ' n; echo got:$n","timeout_ms":1500}"#, &t, &cancel, &ids);
assert!(ok, "{out}");
let id: u64 = out.split('#').nth(1).and_then(|s| s.split(|c: char| !c.is_ascii_digit()).next()).and_then(|s| s.parse().ok()).expect("terminal id");
let (out, ok) = run_on("write_terminal", &format!(r#"{{"id":{id},"input":"daniel\n"}}"#), &t, &cancel, &ids);
println!("write_terminal: {out}");
assert!(ok && out.contains("got:daniel"), "{out}");
// screen
let (txt, img) = computer_run("computer_use_screenshot", &serde_json::json!({})).unwrap();
println!("{txt}");
assert!(img.is_some());
run_on("shell", r#"{"command":"(xterm >/dev/null 2>&1 &) ; sleep 1.5"}"#, &t, &cancel, &ids);
computer_run("computer_use_click", &serde_json::json!({"x": 200, "y": 150})).unwrap();
computer_run("computer_use_typing", &serde_json::json!({"value": "echo 中文輸入OK > /home/bot/typed.txt"})).unwrap();
computer_run("computer_use_press_key", &serde_json::json!({"key": "return"})).unwrap();
std::thread::sleep(Duration::from_millis(800));
let (out, _) = run_on("shell", r#"{"command":"cat /home/bot/typed.txt"}"#, &t, &cancel, &ids);
println!("typed: {out}");
assert!(out.contains("中文輸入OK"), "{out}");
// snapshot + restore
save_snapshot().unwrap();
run_on("shell", r#"{"command":"rm -rf notes"}"#, &t, &cancel, &ids);
rebuild(Rebuild::Restore).unwrap();
let start = Instant::now();
while shared().lock().unwrap().rebuilding && start.elapsed() < Duration::from_secs(180) {
std::thread::sleep(Duration::from_millis(500));
}
println!("after restore: {:?}", run_state());
let (out, ok) = run_on("read_file", r#"{"path":"notes/hello.txt"}"#, &t, &cancel, &ids);
assert!(ok && out.contains("你好 世界"), "{out}");
let _ = run_on("shell", r#"{"command":"rm -rf /home/bot/bot-999 /home/bot/typed.txt"}"#, &t, &cancel, &ids);
}
/// `cargo test --release -- --ignored box_traffic_via_mac --nocapture`
#[test]
#[ignore]
fn box_traffic_via_mac() {
use crate::tools::{run_on, Place, RunIds, Target};
let cancel = AtomicBool::new(false);
ensure_running().unwrap();
set_egress_via_mac(true).unwrap();
let before = crate::egress::CONNECTIONS.load(Ordering::SeqCst);
let t = Target { name: "Bot 的電腦".into(), place: Place::Box { dir: bot_dir(997) } };
let (out, ok) = run_on("shell", r#"{"command":"curl -sS -o /dev/null -w '%{http_code}' https://example.com"}"#, &t, &cancel, &RunIds::default());
set_egress_via_mac(false).unwrap();
println!("{out} via mac: {}", crate::egress::CONNECTIONS.load(Ordering::SeqCst) - before);
assert!(ok && out.contains("200"), "{out}");
assert!(crate::egress::CONNECTIONS.load(Ordering::SeqCst) > before);
}
/// `cargo test --release -- --ignored box_update_keeps_files --nocapture`
#[test]
#[ignore]
fn box_update_keeps_files() {
use crate::tools::{run_on, Place, RunIds, Target};
let cancel = AtomicBool::new(false);
let ids = RunIds::default();
ensure_running().unwrap();
let t = Target { name: "Bot 的電腦".into(), place: Place::Box { dir: bot_dir(998) } };
let (o, ok) = run_on("shell", r#"{"command":"echo keep-me > kept.txt && sudo apt-get update -qq >/dev/null 2>&1 && sudo apt-get install -y -qq cowsay >/dev/null 2>&1; ls /usr/games/cowsay","timeout_ms":180000}"#, &t, &cancel, &ids);
assert!(ok, "{o}");
rebuild(Rebuild::Update).unwrap();
assert!(rebuild(Rebuild::Reset).is_err(), "only one rebuild at a time");
let start = Instant::now();
while shared().lock().unwrap().rebuilding && start.elapsed() < Duration::from_secs(600) {
std::thread::sleep(Duration::from_millis(500));
}
for l in log_lines() {
println!("{l}");
}
assert!(run_state().is_running(), "{:?}", run_state());
let (out, ok) = run_on("shell", r#"{"command":"cat kept.txt; ls /usr/games/cowsay 2>&1 || echo no-cowsay; cat /etc/anybot/menu | head -1"}"#, &t, &cancel, &ids);
println!("{out}");
assert!(ok && out.contains("keep-me") && out.contains("no-cowsay") && out.contains("Bot 的電腦"));
let _ = run_on("shell", r#"{"command":"cd .. && rm -rf bot-998"}"#, &t, &cancel, &ids);
}
#[test]
fn schedule_window_is_one_hour() {
let t = |s: &str| chrono::NaiveTime::parse_from_str(s, "%H:%M").unwrap();
assert!(in_schedule_window("03:00", t("03:00")));
assert!(in_schedule_window("03:00", t("03:59")));
assert!(!in_schedule_window("03:00", t("04:00")));
assert!(!in_schedule_window("03:00", t("02:59")));
assert!(in_schedule_window("23:30", t("00:10")));
assert!(!in_schedule_window("bad", t("00:10")));
assert_eq!(wanted_version(), "3");
}
#[test]
fn wrapper_runs_as_the_bot_in_its_folder() {
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"));
}
}