// 「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; /// Idle time before the box goes to sleep (estimated; the original parks an idle box). pub const IDLE_SLEEP_SECS: u64 = 900; /// 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, /// 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, } /// 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, /// Screenshot pixel → desktop pixel (for the bot's coordinates). scale: Option<(f64, f64)>, tunnel: Option, host_port: u16, } fn shared() -> &'static Mutex { static S: OnceLock> = 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 { static C: OnceLock> = OnceLock::new(); C.get_or_init(|| Mutex::new(load_config())) } 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 { 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::() + " -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 { config().lock().unwrap().auto_update_at.clone() } pub fn set_auto_update_at(at: Option) { 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> { std::fs::metadata(snapshot_path()).ok()?.modified().ok().map(chrono::DateTime::::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 = 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 { 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()) } } fn dk_timeout(args: &[&str], ms: u64) -> Result, 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); let r = dk(&args); 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 = 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}"); 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)))?; 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 { static PORT: Mutex = Mutex::new(0); let mut p = PORT.lock().unwrap(); if *p != 0 { return Ok(*p); } let l = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?; let port = l.local_addr().map_err(|e| e.to_string())?.port(); std::thread::spawn(move || { for conn in l.incoming().flatten() { std::thread::spawn(move || { let script = format!("exec 3<>/dev/tcp/127.0.0.1/{BOX_CDP_PORT} || exit 1; cat <&3 & cat >&3; kill $! 2>/dev/null"); let Ok(mut child) = docker().args(["exec", "-i", CONTAINER, "bash", "-c", &script]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { return }; let (Some(mut cin), Some(mut cout)) = (child.stdin.take(), child.stdout.take()) else { return }; let (Ok(mut r), Ok(mut w)) = (conn.try_clone(), conn.try_clone()) else { return }; let up = std::thread::spawn(move || { let _ = std::io::copy(&mut r, &mut cin); }); let _ = std::io::copy(&mut cout, &mut w); let _ = conn.shutdown(std::net::Shutdown::Both); let _ = child.kill(); let _ = up.join(); let _ = child.wait(); }); } }); *p = port; Ok(port) } /// Is the box's Chromium answering already? pub fn browser_running() -> bool { box_cdp_forward().map(box_cdp_alive).unwrap_or(false) } /// The box's Chromium, started on its desktop when it isn't running. Gives the local port. pub fn browser_port() -> Result { ensure_running()?; touch(); let port = box_cdp_forward()?; if box_cdp_alive(port) { return Ok(port); } let dbg = format!("--remote-debugging-port={BOX_CDP_PORT}"); dk(&["exec", "-d", "-u", "bot", "-e", "DISPLAY=:1", "-e", "HOME=/home/bot", CONTAINER, "chromium", &dbg, "--remote-allow-origins=*", "--user-data-dir=/home/bot/.config/anybot-browser", "about:blank"]).map_err(|e| format!("Bot 的電腦打不開瀏覽器:{e}"))?; let start = Instant::now(); while start.elapsed() < Duration::from_secs(20) { if box_cdp_alive(port) { return Ok(port); } std::thread::sleep(Duration::from_millis(300)); } Err("Bot 的電腦的瀏覽器沒有及時打開,請再試一次。".into()) } static START_LOCK: Mutex<()> = Mutex::new(()); /// Make sure the box is up, waking or creating it as needed. Blocks until it's ready. 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 } /// Put the box to sleep now (saves a snapshot first, like the original's flush before parking). pub fn sleep_now() -> Result<(), String> { if !matches!(probe(), Run::Running) { return Ok(()); } if is_busy() { return Err("有 Bot 正在用這台電腦,等它做完再休眠。".into()); } let _ = save_snapshot(); log("電腦進入休眠。"); dk(&["pause", CONTAINER]).map(|_| set_run(Run::Asleep)) } /// Started once: puts an idle box to sleep, and replays a rebuild cut short by a restart. 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 { std::thread::sleep(Duration::from_secs(20)); let (idle, running, rebuilding) = { let s = shared().lock().unwrap(); (s.last_used.elapsed().as_secs(), s.run.is_running(), s.rebuilding) }; if running && !rebuilding && !is_busy() && !HELD_BY_PERSON.load(Ordering::SeqCst) && idle >= IDLE_SLEEP_SECS && !viewer_open() { let _ = sleep_now(); } if !rebuilding { refresh(); maybe_scheduled_update(); } } }); } 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 { 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())?; 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)); }; 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::(&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 { 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 { 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) -> 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 { 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()); } 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) -> String { use crate::tools::sh_quote; let inner = format!("mkdir -p {d} && cd {d} && {cmd}", 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)) } /// Put a file into a bot's folder on the box (only when the box is already running). pub fn copy_in(local: &str, bot_id: u64, name: &str) -> Result { if !run_state().is_running() { return Err("Bot 的電腦沒有在執行".into()); } let dir = format!("{}/附件", bot_dir(bot_id)); dk(&["exec", "-u", "bot", CONTAINER, "mkdir", "-p", &dir])?; let dst = format!("{dir}/{name}"); 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 { 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 { 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 { 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 { 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 { 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 { 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::().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 { 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 = 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 { 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> { 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("使用者正在自己操作 Bot 的電腦(已取得控制權)。請等他交還控制權後再操作,或先截圖看看畫面。".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| -> Result { for m in mods { xdo(&["keydown", m])?; } let r = f(); for m in mods.iter().rev() { let _ = xdo(&["keyup", m]); } r }; match name { "computer_use_check_permissions" => Ok(("Bot 的電腦不需要另外的權限:可以截圖,也可以操作滑鼠和鍵盤。".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!("Bot 的電腦的螢幕截圖({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()?; let path: Vec<(String, String)> = v["path"].as_array().map(|a| a.iter().filter_map(|p| pt(p, s).ok()).collect()).unwrap_or_default(); 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 = keys.split_whitespace().map(combo_to_x).collect::>()?; 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 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 { 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 }; 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" })) } 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")); } }